"""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 = (
"✅ Пополнение успешно!\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"⚠️ Важно: Пополнение баланса не активирует подписку автоматически. "
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}