mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-01-19 19:32:10 +00:00
Новый функционал: - Быстрая проверка (TRAFFIC_FAST_CHECK_*) — отслеживает дельту трафика за интервал через snapshot - Суточная проверка (TRAFFIC_DAILY_CHECK_*) — анализирует трафик за 24 часа через bandwidth API - Фильтрация по нодам (TRAFFIC_MONIT
802 lines
33 KiB
Python
802 lines
33 KiB
Python
"""Mixin с логикой обработки платежей CryptoBot."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
from dataclasses import dataclass
|
||
from datetime import datetime
|
||
from importlib import import_module
|
||
from typing import Any, Dict, Optional
|
||
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.config import settings
|
||
from app.database.database import AsyncSessionLocal
|
||
from app.database.models import PaymentMethod, TransactionType
|
||
from app.services.subscription_auto_purchase_service import (
|
||
auto_activate_subscription_after_topup,
|
||
auto_purchase_saved_cart_after_topup,
|
||
)
|
||
from app.services.subscription_renewal_service import (
|
||
SubscriptionRenewalChargeError,
|
||
SubscriptionRenewalPricing,
|
||
SubscriptionRenewalService,
|
||
RenewalPaymentDescriptor,
|
||
build_renewal_period_id,
|
||
decode_payment_payload,
|
||
parse_payment_metadata,
|
||
)
|
||
from app.utils.currency_converter import currency_converter
|
||
from app.utils.user_utils import format_referrer_info
|
||
from app.utils.payment_logger import payment_logger as logger
|
||
|
||
|
||
renewal_service = SubscriptionRenewalService()
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class _AdminNotificationContext:
|
||
user_id: int
|
||
transaction_id: int
|
||
old_balance: int
|
||
topup_status: str
|
||
referrer_info: str
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class _UserNotificationPayload:
|
||
telegram_id: int
|
||
text: str
|
||
parse_mode: Optional[str]
|
||
reply_markup: Any
|
||
amount_rubles: float
|
||
asset: str
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class _SavedCartNotificationPayload:
|
||
telegram_id: int
|
||
text: str
|
||
reply_markup: Any
|
||
user_id: int
|
||
|
||
|
||
class CryptoBotPaymentMixin:
|
||
"""Mixin, отвечающий за генерацию инвойсов CryptoBot и обработку webhook."""
|
||
|
||
async def create_cryptobot_payment(
|
||
self,
|
||
db: AsyncSession,
|
||
user_id: int,
|
||
amount_usd: float,
|
||
asset: str = "USDT",
|
||
description: str = "Пополнение баланса",
|
||
payload: Optional[str] = None,
|
||
) -> Optional[Dict[str, Any]]:
|
||
"""Создаёт invoice в CryptoBot и сохраняет локальную запись."""
|
||
if not getattr(self, "cryptobot_service", None):
|
||
logger.error("CryptoBot сервис не инициализирован")
|
||
return None
|
||
|
||
try:
|
||
amount_str = f"{amount_usd:.2f}"
|
||
|
||
invoice_data = await self.cryptobot_service.create_invoice(
|
||
amount=amount_str,
|
||
asset=asset,
|
||
description=description,
|
||
payload=payload or f"balance_topup_{user_id}_{int(amount_usd * 100)}",
|
||
expires_in=settings.get_cryptobot_invoice_expires_seconds(),
|
||
)
|
||
|
||
if not invoice_data:
|
||
logger.error("Ошибка создания CryptoBot invoice")
|
||
return None
|
||
|
||
cryptobot_crud = import_module("app.database.crud.cryptobot")
|
||
|
||
local_payment = await cryptobot_crud.create_cryptobot_payment(
|
||
db=db,
|
||
user_id=user_id,
|
||
invoice_id=str(invoice_data["invoice_id"]),
|
||
amount=amount_str,
|
||
asset=asset,
|
||
status="active",
|
||
description=description,
|
||
payload=payload,
|
||
bot_invoice_url=invoice_data.get("bot_invoice_url"),
|
||
mini_app_invoice_url=invoice_data.get("mini_app_invoice_url"),
|
||
web_app_invoice_url=invoice_data.get("web_app_invoice_url"),
|
||
)
|
||
|
||
logger.info(
|
||
"Создан CryptoBot платеж %s на %s %s для пользователя %s",
|
||
invoice_data["invoice_id"],
|
||
amount_str,
|
||
asset,
|
||
user_id,
|
||
)
|
||
|
||
return {
|
||
"local_payment_id": local_payment.id,
|
||
"invoice_id": str(invoice_data["invoice_id"]),
|
||
"amount": amount_str,
|
||
"asset": asset,
|
||
"bot_invoice_url": invoice_data.get("bot_invoice_url"),
|
||
"mini_app_invoice_url": invoice_data.get("mini_app_invoice_url"),
|
||
"web_app_invoice_url": invoice_data.get("web_app_invoice_url"),
|
||
"status": "active",
|
||
"created_at": (
|
||
local_payment.created_at.isoformat()
|
||
if local_payment.created_at
|
||
else None
|
||
),
|
||
}
|
||
|
||
except Exception as error:
|
||
logger.error("Ошибка создания CryptoBot платежа: %s", error)
|
||
return None
|
||
|
||
async def process_cryptobot_webhook(
|
||
self,
|
||
db: AsyncSession,
|
||
webhook_data: Dict[str, Any],
|
||
) -> bool:
|
||
"""Обрабатывает webhook от CryptoBot и начисляет средства пользователю."""
|
||
try:
|
||
update_type = webhook_data.get("update_type")
|
||
|
||
if update_type != "invoice_paid":
|
||
logger.info("Пропуск CryptoBot webhook с типом: %s", update_type)
|
||
return True
|
||
|
||
payload = webhook_data.get("payload", {})
|
||
invoice_id = str(payload.get("invoice_id"))
|
||
status = "paid"
|
||
|
||
if not invoice_id:
|
||
logger.error("CryptoBot webhook без invoice_id")
|
||
return False
|
||
|
||
cryptobot_crud = import_module("app.database.crud.cryptobot")
|
||
payment = await cryptobot_crud.get_cryptobot_payment_by_invoice_id(
|
||
db, invoice_id
|
||
)
|
||
if not payment:
|
||
logger.error("CryptoBot платеж не найден в БД: %s", invoice_id)
|
||
return False
|
||
|
||
if payment.status == "paid":
|
||
logger.info("CryptoBot платеж %s уже обработан", invoice_id)
|
||
return True
|
||
|
||
paid_at_str = payload.get("paid_at")
|
||
if paid_at_str:
|
||
try:
|
||
paid_at = datetime.fromisoformat(
|
||
paid_at_str.replace("Z", "+00:00")
|
||
).replace(tzinfo=None)
|
||
except Exception:
|
||
paid_at = datetime.utcnow()
|
||
else:
|
||
paid_at = datetime.utcnow()
|
||
|
||
updated_payment = await cryptobot_crud.update_cryptobot_payment_status(
|
||
db, invoice_id, status, paid_at
|
||
)
|
||
|
||
descriptor = decode_payment_payload(
|
||
getattr(updated_payment, "payload", "") or "",
|
||
expected_user_id=updated_payment.user_id,
|
||
)
|
||
|
||
if descriptor is None:
|
||
inline_payload = payload.get("payload")
|
||
if isinstance(inline_payload, str) and inline_payload:
|
||
descriptor = decode_payment_payload(
|
||
inline_payload,
|
||
expected_user_id=updated_payment.user_id,
|
||
)
|
||
|
||
if descriptor is None:
|
||
metadata = payload.get("metadata")
|
||
if isinstance(metadata, dict) and metadata:
|
||
descriptor = parse_payment_metadata(
|
||
metadata,
|
||
expected_user_id=updated_payment.user_id,
|
||
)
|
||
if descriptor:
|
||
renewal_handled = await self._process_subscription_renewal_payment(
|
||
db,
|
||
updated_payment,
|
||
descriptor,
|
||
cryptobot_crud,
|
||
)
|
||
if renewal_handled:
|
||
return True
|
||
|
||
if not updated_payment.transaction_id:
|
||
amount_usd = updated_payment.amount_float
|
||
|
||
try:
|
||
amount_rubles = await currency_converter.usd_to_rub(amount_usd)
|
||
amount_rubles_rounded = math.ceil(amount_rubles)
|
||
amount_kopeks = int(amount_rubles_rounded * 100)
|
||
conversion_rate = (
|
||
amount_rubles / amount_usd if amount_usd > 0 else 0
|
||
)
|
||
logger.info(
|
||
"Конвертация USD->RUB: $%s -> %s₽ (округлено до %s₽, курс: %.2f)",
|
||
amount_usd,
|
||
amount_rubles,
|
||
amount_rubles_rounded,
|
||
conversion_rate,
|
||
)
|
||
except Exception as error:
|
||
logger.warning(
|
||
"Ошибка конвертации валют для платежа %s, используем курс 1:1: %s",
|
||
invoice_id,
|
||
error,
|
||
)
|
||
amount_rubles = amount_usd
|
||
amount_rubles_rounded = math.ceil(amount_rubles)
|
||
amount_kopeks = int(amount_rubles_rounded * 100)
|
||
conversion_rate = 1.0
|
||
|
||
if amount_kopeks <= 0:
|
||
logger.error(
|
||
"Некорректная сумма после конвертации: %s копеек для платежа %s",
|
||
amount_kopeks,
|
||
invoice_id,
|
||
)
|
||
return False
|
||
|
||
payment_service_module = import_module("app.services.payment_service")
|
||
transaction = await payment_service_module.create_transaction(
|
||
db,
|
||
user_id=updated_payment.user_id,
|
||
type=TransactionType.DEPOSIT,
|
||
amount_kopeks=amount_kopeks,
|
||
description=(
|
||
"Пополнение через CryptoBot "
|
||
f"({updated_payment.amount} {updated_payment.asset} → {amount_rubles_rounded:.2f}₽)"
|
||
),
|
||
payment_method=PaymentMethod.CRYPTOBOT,
|
||
external_id=invoice_id,
|
||
is_completed=True,
|
||
)
|
||
|
||
await cryptobot_crud.link_cryptobot_payment_to_transaction(
|
||
db, invoice_id, transaction.id
|
||
)
|
||
|
||
get_user_by_id = payment_service_module.get_user_by_id
|
||
user = await get_user_by_id(db, updated_payment.user_id)
|
||
if not user:
|
||
logger.error(
|
||
"Пользователь с ID %s не найден при пополнении баланса",
|
||
updated_payment.user_id,
|
||
)
|
||
return False
|
||
|
||
old_balance = user.balance_kopeks
|
||
was_first_topup = not user.has_made_first_topup
|
||
|
||
user.balance_kopeks += amount_kopeks
|
||
user.updated_at = datetime.utcnow()
|
||
|
||
referrer_info = format_referrer_info(user)
|
||
topup_status = (
|
||
"🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
|
||
)
|
||
|
||
await db.commit()
|
||
|
||
try:
|
||
from app.services.referral_service import process_referral_topup
|
||
|
||
await process_referral_topup(
|
||
db,
|
||
user.id,
|
||
amount_kopeks,
|
||
getattr(self, "bot", None),
|
||
)
|
||
except Exception as error:
|
||
logger.error(
|
||
"Ошибка обработки реферального пополнения CryptoBot: %s",
|
||
error,
|
||
)
|
||
|
||
if was_first_topup and not user.has_made_first_topup:
|
||
user.has_made_first_topup = True
|
||
await db.commit()
|
||
|
||
await db.refresh(user)
|
||
|
||
admin_notification: Optional[_AdminNotificationContext] = None
|
||
user_notification: Optional[_UserNotificationPayload] = None
|
||
saved_cart_notification: Optional[_SavedCartNotificationPayload] = None
|
||
|
||
bot_instance = getattr(self, "bot", None)
|
||
if bot_instance:
|
||
admin_notification = _AdminNotificationContext(
|
||
user_id=user.id,
|
||
transaction_id=transaction.id,
|
||
old_balance=old_balance,
|
||
topup_status=topup_status,
|
||
referrer_info=referrer_info,
|
||
)
|
||
|
||
try:
|
||
keyboard = await self.build_topup_success_keyboard(user)
|
||
message_text = (
|
||
"✅ <b>Пополнение успешно!</b>\n\n"
|
||
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
||
f"🪙 Платеж: {updated_payment.amount} {updated_payment.asset}\n"
|
||
f"💱 Курс: 1 USD = {conversion_rate:.2f}₽\n"
|
||
f"🆔 Транзакция: {invoice_id[:8]}...\n\n"
|
||
"Баланс пополнен автоматически!"
|
||
)
|
||
user_notification = _UserNotificationPayload(
|
||
telegram_id=user.telegram_id,
|
||
text=message_text,
|
||
parse_mode="HTML",
|
||
reply_markup=keyboard,
|
||
amount_rubles=amount_rubles_rounded,
|
||
asset=updated_payment.asset,
|
||
)
|
||
except Exception as error:
|
||
logger.error(
|
||
"Ошибка подготовки уведомления о пополнении CryptoBot: %s",
|
||
error,
|
||
)
|
||
|
||
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
|
||
try:
|
||
from app.services.user_cart_service import user_cart_service
|
||
from aiogram import types
|
||
|
||
has_saved_cart = await user_cart_service.has_user_cart(user.id)
|
||
auto_purchase_success = False
|
||
if has_saved_cart:
|
||
try:
|
||
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
|
||
db,
|
||
user,
|
||
bot=bot_instance,
|
||
)
|
||
except Exception as auto_error:
|
||
logger.error(
|
||
"Ошибка автоматической покупки подписки для пользователя %s: %s",
|
||
user.id,
|
||
auto_error,
|
||
exc_info=True,
|
||
)
|
||
|
||
if auto_purchase_success:
|
||
has_saved_cart = False
|
||
|
||
# Умная автоактивация если автопокупка не сработала
|
||
activation_notification_sent = False
|
||
if not auto_purchase_success:
|
||
try:
|
||
_, activation_notification_sent = await auto_activate_subscription_after_topup(
|
||
db,
|
||
user,
|
||
bot=bot_instance,
|
||
topup_amount=amount_kopeks,
|
||
)
|
||
except Exception as auto_activate_error:
|
||
logger.error(
|
||
"Ошибка умной автоактивации для пользователя %s: %s",
|
||
user.id,
|
||
auto_activate_error,
|
||
exc_info=True,
|
||
)
|
||
|
||
# Отправляем уведомление только если его ещё не отправили
|
||
if has_saved_cart and bot_instance and not activation_notification_sent:
|
||
from app.localization.texts import get_texts
|
||
|
||
texts = get_texts(user.language)
|
||
cart_message = texts.BALANCE_TOPUP_CART_REMINDER_DETAILED.format(
|
||
total_amount=settings.format_price(amount_kopeks)
|
||
)
|
||
|
||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||
[types.InlineKeyboardButton(
|
||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||
callback_data="return_to_saved_cart"
|
||
)],
|
||
[types.InlineKeyboardButton(
|
||
text="💰 Мой баланс",
|
||
callback_data="menu_balance"
|
||
)],
|
||
[types.InlineKeyboardButton(
|
||
text="🏠 Главное меню",
|
||
callback_data="back_to_menu"
|
||
)]
|
||
])
|
||
|
||
saved_cart_notification = _SavedCartNotificationPayload(
|
||
telegram_id=user.telegram_id,
|
||
text=(
|
||
f"✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n"
|
||
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
|
||
f"Обязательно активируйте подписку отдельно!\n\n"
|
||
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
|
||
f"подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}"
|
||
),
|
||
reply_markup=keyboard,
|
||
user_id=user.id,
|
||
)
|
||
except Exception as error:
|
||
logger.error(
|
||
"Ошибка при работе с сохраненной корзиной для пользователя %s: %s",
|
||
user.id,
|
||
error,
|
||
exc_info=True,
|
||
)
|
||
|
||
if admin_notification:
|
||
await self._deliver_admin_topup_notification(admin_notification)
|
||
|
||
if user_notification and bot_instance:
|
||
await self._deliver_user_topup_notification(user_notification)
|
||
|
||
if saved_cart_notification and bot_instance:
|
||
await self._deliver_saved_cart_reminder(saved_cart_notification)
|
||
|
||
return True
|
||
|
||
except Exception as error:
|
||
logger.error(
|
||
"Ошибка обработки CryptoBot webhook: %s", error, exc_info=True
|
||
)
|
||
return False
|
||
|
||
async def _process_subscription_renewal_payment(
|
||
self,
|
||
db: AsyncSession,
|
||
payment: Any,
|
||
descriptor: RenewalPaymentDescriptor,
|
||
cryptobot_crud: Any,
|
||
) -> bool:
|
||
try:
|
||
payment_service_module = import_module("app.services.payment_service")
|
||
user = await payment_service_module.get_user_by_id(db, payment.user_id)
|
||
except Exception as error:
|
||
logger.error(
|
||
"Не удалось загрузить пользователя %s для продления через CryptoBot: %s",
|
||
getattr(payment, "user_id", None),
|
||
error,
|
||
)
|
||
return False
|
||
|
||
if not user:
|
||
logger.error(
|
||
"Пользователь %s не найден при обработке продления через CryptoBot",
|
||
getattr(payment, "user_id", None),
|
||
)
|
||
return False
|
||
|
||
subscription = getattr(user, "subscription", None)
|
||
if not subscription or subscription.id != descriptor.subscription_id:
|
||
logger.warning(
|
||
"Продление через CryptoBot отклонено: подписка %s не совпадает с ожидаемой %s",
|
||
getattr(subscription, "id", None),
|
||
descriptor.subscription_id,
|
||
)
|
||
return False
|
||
|
||
pricing_model: Optional[SubscriptionRenewalPricing] = None
|
||
if descriptor.pricing_snapshot:
|
||
try:
|
||
pricing_model = SubscriptionRenewalPricing.from_payload(
|
||
descriptor.pricing_snapshot
|
||
)
|
||
except Exception as error:
|
||
logger.warning(
|
||
"Не удалось восстановить сохраненную стоимость продления из payload %s: %s",
|
||
payment.invoice_id,
|
||
error,
|
||
)
|
||
|
||
if pricing_model is None:
|
||
try:
|
||
pricing_model = await renewal_service.calculate_pricing(
|
||
db,
|
||
user,
|
||
subscription,
|
||
descriptor.period_days,
|
||
)
|
||
except Exception as error:
|
||
logger.error(
|
||
"Не удалось пересчитать стоимость продления для CryptoBot %s: %s",
|
||
payment.invoice_id,
|
||
error,
|
||
)
|
||
return False
|
||
|
||
if pricing_model.final_total != descriptor.total_amount_kopeks:
|
||
logger.warning(
|
||
"Сумма продления через CryptoBot %s изменилась (ожидалось %s, получено %s)",
|
||
payment.invoice_id,
|
||
descriptor.total_amount_kopeks,
|
||
pricing_model.final_total,
|
||
)
|
||
pricing_model.final_total = descriptor.total_amount_kopeks
|
||
pricing_model.per_month = (
|
||
descriptor.total_amount_kopeks // pricing_model.months
|
||
if pricing_model.months
|
||
else descriptor.total_amount_kopeks
|
||
)
|
||
|
||
pricing_model.period_days = descriptor.period_days
|
||
pricing_model.period_id = build_renewal_period_id(descriptor.period_days)
|
||
|
||
required_balance = max(
|
||
0,
|
||
min(
|
||
pricing_model.final_total,
|
||
descriptor.balance_component_kopeks,
|
||
),
|
||
)
|
||
|
||
current_balance = getattr(user, "balance_kopeks", 0)
|
||
if current_balance < required_balance:
|
||
logger.warning(
|
||
"Недостаточно средств на балансе пользователя %s для завершения продления: нужно %s, доступно %s",
|
||
user.id,
|
||
required_balance,
|
||
current_balance,
|
||
)
|
||
return False
|
||
|
||
description = f"Продление подписки на {descriptor.period_days} дней"
|
||
|
||
try:
|
||
result = await renewal_service.finalize(
|
||
db,
|
||
user,
|
||
subscription,
|
||
pricing_model,
|
||
charge_balance_amount=required_balance,
|
||
description=description,
|
||
payment_method=PaymentMethod.CRYPTOBOT,
|
||
)
|
||
except SubscriptionRenewalChargeError as error:
|
||
logger.error(
|
||
"Списание баланса не выполнено при продлении через CryptoBot %s: %s",
|
||
payment.invoice_id,
|
||
error,
|
||
)
|
||
return False
|
||
except Exception as error:
|
||
logger.error(
|
||
"Ошибка завершения продления через CryptoBot %s: %s",
|
||
payment.invoice_id,
|
||
error,
|
||
exc_info=True,
|
||
)
|
||
return False
|
||
|
||
transaction = result.transaction
|
||
if transaction:
|
||
try:
|
||
await cryptobot_crud.link_cryptobot_payment_to_transaction(
|
||
db,
|
||
payment.invoice_id,
|
||
transaction.id,
|
||
)
|
||
except Exception as error:
|
||
logger.warning(
|
||
"Не удалось связать платеж CryptoBot %s с транзакцией %s: %s",
|
||
payment.invoice_id,
|
||
transaction.id,
|
||
error,
|
||
)
|
||
|
||
external_amount_label = settings.format_price(descriptor.missing_amount_kopeks)
|
||
balance_amount_label = settings.format_price(required_balance)
|
||
|
||
logger.info(
|
||
"Подписка %s продлена через CryptoBot invoice %s (внешний платеж %s, списано с баланса %s)",
|
||
subscription.id,
|
||
payment.invoice_id,
|
||
external_amount_label,
|
||
balance_amount_label,
|
||
)
|
||
|
||
return True
|
||
|
||
async def _deliver_admin_topup_notification(
|
||
self, context: _AdminNotificationContext
|
||
) -> None:
|
||
bot_instance = getattr(self, "bot", None)
|
||
if not bot_instance:
|
||
return
|
||
|
||
try:
|
||
from app.services.admin_notification_service import AdminNotificationService
|
||
from app.database.crud.user import get_user_by_id
|
||
from app.database.crud.transaction import get_transaction_by_id
|
||
except Exception as error:
|
||
logger.error(
|
||
"Не удалось импортировать зависимости для админ-уведомления CryptoBot: %s",
|
||
error,
|
||
exc_info=True,
|
||
)
|
||
return
|
||
|
||
async with AsyncSessionLocal() as session:
|
||
try:
|
||
user = await get_user_by_id(session, context.user_id)
|
||
transaction = await get_transaction_by_id(session, context.transaction_id)
|
||
except Exception as error:
|
||
logger.error(
|
||
"Ошибка загрузки данных для админ-уведомления CryptoBot: %s",
|
||
error,
|
||
exc_info=True,
|
||
)
|
||
await session.rollback()
|
||
return
|
||
|
||
if not user or not transaction:
|
||
logger.warning(
|
||
"Пропущена отправка админ-уведомления CryptoBot: user=%s transaction=%s",
|
||
bool(user),
|
||
bool(transaction),
|
||
)
|
||
return
|
||
|
||
notification_service = AdminNotificationService(bot_instance)
|
||
try:
|
||
await notification_service.send_balance_topup_notification(
|
||
user,
|
||
transaction,
|
||
context.old_balance,
|
||
topup_status=context.topup_status,
|
||
referrer_info=context.referrer_info,
|
||
subscription=getattr(user, "subscription", None),
|
||
promo_group=getattr(user, "promo_group", None),
|
||
db=session,
|
||
)
|
||
except Exception as error:
|
||
logger.error(
|
||
"Ошибка отправки админ-уведомления о пополнении CryptoBot: %s",
|
||
error,
|
||
exc_info=True,
|
||
)
|
||
|
||
async def _deliver_user_topup_notification(
|
||
self, payload: _UserNotificationPayload
|
||
) -> None:
|
||
bot_instance = getattr(self, "bot", None)
|
||
if not bot_instance:
|
||
return
|
||
|
||
try:
|
||
await bot_instance.send_message(
|
||
payload.telegram_id,
|
||
payload.text,
|
||
parse_mode=payload.parse_mode,
|
||
reply_markup=payload.reply_markup,
|
||
)
|
||
logger.info(
|
||
"✅ Отправлено уведомление пользователю %s о пополнении на %s₽ (%s)",
|
||
payload.telegram_id,
|
||
f"{payload.amount_rubles:.2f}",
|
||
payload.asset,
|
||
)
|
||
except Exception as error:
|
||
logger.error(
|
||
"Ошибка отправки уведомления о пополнении CryptoBot: %s",
|
||
error,
|
||
)
|
||
|
||
async def _deliver_saved_cart_reminder(
|
||
self, payload: _SavedCartNotificationPayload
|
||
) -> None:
|
||
bot_instance = getattr(self, "bot", None)
|
||
if not bot_instance:
|
||
return
|
||
|
||
try:
|
||
await bot_instance.send_message(
|
||
chat_id=payload.telegram_id,
|
||
text=payload.text,
|
||
reply_markup=payload.reply_markup,
|
||
)
|
||
logger.info(
|
||
"Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s",
|
||
payload.user_id,
|
||
)
|
||
except Exception as error:
|
||
logger.error(
|
||
"Ошибка отправки уведомления о сохраненной корзине для пользователя %s: %s",
|
||
payload.user_id,
|
||
error,
|
||
exc_info=True,
|
||
)
|
||
|
||
async def get_cryptobot_payment_status(
|
||
self,
|
||
db: AsyncSession,
|
||
local_payment_id: int,
|
||
) -> Optional[Dict[str, Any]]:
|
||
"""Запрашивает актуальный статус CryptoBot invoice и синхронизирует его."""
|
||
|
||
cryptobot_crud = import_module("app.database.crud.cryptobot")
|
||
payment = await cryptobot_crud.get_cryptobot_payment_by_id(db, local_payment_id)
|
||
if not payment:
|
||
logger.warning("CryptoBot платеж %s не найден", local_payment_id)
|
||
return None
|
||
|
||
if not self.cryptobot_service:
|
||
logger.warning("CryptoBot сервис не инициализирован для ручной проверки")
|
||
return {"payment": payment}
|
||
|
||
invoice_id = payment.invoice_id
|
||
try:
|
||
invoices = await self.cryptobot_service.get_invoices(
|
||
invoice_ids=[invoice_id]
|
||
)
|
||
except Exception as error: # pragma: no cover - network errors
|
||
logger.error(
|
||
"Ошибка запроса статуса CryptoBot invoice %s: %s",
|
||
invoice_id,
|
||
error,
|
||
)
|
||
return {"payment": payment}
|
||
|
||
remote_invoice: Optional[Dict[str, Any]] = None
|
||
if invoices:
|
||
for item in invoices:
|
||
if str(item.get("invoice_id")) == str(invoice_id):
|
||
remote_invoice = item
|
||
break
|
||
|
||
if not remote_invoice:
|
||
logger.info(
|
||
"CryptoBot invoice %s не найден через API при ручной проверке",
|
||
invoice_id,
|
||
)
|
||
refreshed = await cryptobot_crud.get_cryptobot_payment_by_id(db, local_payment_id)
|
||
return {"payment": refreshed or payment}
|
||
|
||
status = (remote_invoice.get("status") or "").lower()
|
||
paid_at_str = remote_invoice.get("paid_at")
|
||
paid_at = None
|
||
if paid_at_str:
|
||
try:
|
||
paid_at = datetime.fromisoformat(paid_at_str.replace("Z", "+00:00")).replace(
|
||
tzinfo=None
|
||
)
|
||
except Exception: # pragma: no cover - defensive parsing
|
||
paid_at = None
|
||
|
||
if status == "paid":
|
||
webhook_payload = {
|
||
"update_type": "invoice_paid",
|
||
"payload": {
|
||
"invoice_id": remote_invoice.get("invoice_id") or invoice_id,
|
||
"amount": remote_invoice.get("amount") or payment.amount,
|
||
"asset": remote_invoice.get("asset") or payment.asset,
|
||
"paid_at": paid_at_str,
|
||
"payload": remote_invoice.get("payload") or payment.payload,
|
||
},
|
||
}
|
||
await self.process_cryptobot_webhook(db, webhook_payload)
|
||
else:
|
||
if status and status != (payment.status or "").lower():
|
||
await cryptobot_crud.update_cryptobot_payment_status(
|
||
db,
|
||
invoice_id,
|
||
status,
|
||
paid_at,
|
||
)
|
||
|
||
refreshed = await cryptobot_crud.get_cryptobot_payment_by_id(db, local_payment_id)
|
||
return {"payment": refreshed or payment}
|