9a2aea038a
- Add pyproject.toml with uv and ruff configuration - Pin Python version to 3.13 via .python-version - Add Makefile commands: lint, format, fix - Apply ruff formatting to entire codebase - Remove unused imports (base64 in yookassa/simple_subscription) - Update .gitignore for new config files
479 lines
22 KiB
Python
479 lines
22 KiB
Python
import json
|
|
import logging
|
|
from datetime import datetime
|
|
from typing import Any
|
|
|
|
from aiogram import Bot
|
|
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
|
|
|
from app.config import settings
|
|
from app.database.crud.transaction import create_transaction, get_transaction_by_external_id
|
|
from app.database.crud.user import get_user_by_telegram_id
|
|
from app.database.database import get_db
|
|
from app.database.models import PaymentMethod, TransactionType
|
|
from app.external.tribute import TributeService as TributeAPI
|
|
from app.services.payment_service import PaymentService
|
|
from app.services.subscription_auto_purchase_service import (
|
|
auto_activate_subscription_after_topup,
|
|
auto_purchase_saved_cart_after_topup,
|
|
)
|
|
from app.utils.user_utils import format_referrer_info
|
|
|
|
|
|
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, user_id: int, amount_kopeks: int, description: str = 'Пополнение баланса'
|
|
) -> str | None:
|
|
if not settings.TRIBUTE_ENABLED:
|
|
logger.warning('Tribute платежи отключены')
|
|
return None
|
|
|
|
try:
|
|
payment_url = await self.tribute_api.create_payment_link(
|
|
user_id=user_id, amount_kopeks=amount_kopeks, description=description
|
|
)
|
|
|
|
if not payment_url:
|
|
return None
|
|
|
|
return payment_url
|
|
|
|
except Exception as e:
|
|
logger.error(f'Ошибка создания Tribute платежа: {e}')
|
|
return None
|
|
|
|
async def process_webhook(self, payload: str) -> dict[str, Any]:
|
|
try:
|
|
webhook_data = json.loads(payload)
|
|
except json.JSONDecodeError:
|
|
logger.error('Некорректный JSON в Tribute webhook')
|
|
return {'status': 'error', 'reason': 'invalid_json'}
|
|
|
|
logger.info(f'Получен Tribute webhook: {json.dumps(webhook_data, ensure_ascii=False)}')
|
|
|
|
processed_data = await self.tribute_api.process_webhook(webhook_data)
|
|
if not processed_data:
|
|
return {'status': 'ignored', 'reason': 'invalid_data'}
|
|
|
|
event_type = processed_data.get('event_type', 'payment')
|
|
status = processed_data.get('status')
|
|
|
|
if event_type == 'payment' and status == 'paid':
|
|
await self._handle_successful_payment(processed_data)
|
|
elif event_type == 'payment' and status == 'failed':
|
|
await self._handle_failed_payment(processed_data)
|
|
elif event_type == 'refund':
|
|
await self._handle_refund(processed_data)
|
|
|
|
return {'status': 'ok', 'event': event_type}
|
|
|
|
async def _handle_successful_payment(self, payment_data: dict[str, Any]):
|
|
try:
|
|
user_telegram_id = payment_data['user_id']
|
|
amount_kopeks = payment_data['amount_kopeks']
|
|
payment_id = payment_data['payment_id']
|
|
|
|
logger.info(
|
|
f'Обрабатываем успешный Tribute платеж: user_telegram_id={user_telegram_id}, amount={amount_kopeks}, payment_id={payment_id}'
|
|
)
|
|
|
|
async for session in get_db():
|
|
user = await get_user_by_telegram_id(session, user_telegram_id)
|
|
if not user:
|
|
logger.error(f'Пользователь {user_telegram_id} не найден')
|
|
return
|
|
|
|
logger.info(f'Найден пользователь {user.telegram_id}, текущий баланс: {user.balance_kopeks} коп')
|
|
|
|
from app.database.crud.transaction import check_tribute_payment_duplicate
|
|
|
|
duplicate_transaction = await check_tribute_payment_duplicate(
|
|
session, payment_id, amount_kopeks, user_telegram_id
|
|
)
|
|
|
|
if duplicate_transaction:
|
|
logger.warning('Найден дубликат платежа в течение 24ч:')
|
|
logger.warning(f' Transaction ID: {duplicate_transaction.id}')
|
|
logger.warning(f' Amount: {duplicate_transaction.amount_kopeks} коп')
|
|
logger.warning(f' Created: {duplicate_transaction.created_at}')
|
|
logger.warning(f' External ID: {duplicate_transaction.external_id}')
|
|
logger.warning('Платеж игнорирован - это дубликат свежего платежа')
|
|
return
|
|
|
|
from app.database.crud.transaction import create_unique_tribute_transaction
|
|
|
|
transaction = await create_unique_tribute_transaction(
|
|
db=session,
|
|
user_id=user.id,
|
|
payment_id=payment_id,
|
|
amount_kopeks=amount_kopeks,
|
|
description=f'Пополнение через Tribute: {amount_kopeks / 100}₽ (ID: {payment_id})',
|
|
)
|
|
|
|
old_balance = user.balance_kopeks
|
|
was_first_topup = not user.has_made_first_topup
|
|
|
|
user.balance_kopeks += amount_kopeks
|
|
user.updated_at = datetime.utcnow()
|
|
|
|
promo_group = user.get_primary_promo_group()
|
|
subscription = getattr(user, 'subscription', None)
|
|
referrer_info = format_referrer_info(user)
|
|
topup_status = '🆕 Первое пополнение' if was_first_topup else '🔄 Пополнение'
|
|
|
|
await session.commit()
|
|
|
|
try:
|
|
from app.services.referral_service import process_referral_topup
|
|
|
|
await process_referral_topup(session, user.id, amount_kopeks, self.bot)
|
|
except Exception as e:
|
|
logger.error(f'Ошибка обработки реферального пополнения Tribute: {e}')
|
|
|
|
if was_first_topup and not user.has_made_first_topup:
|
|
user.has_made_first_topup = True
|
|
await session.commit()
|
|
|
|
await session.refresh(user)
|
|
|
|
logger.info(
|
|
f'✅ Баланс пользователя {user_telegram_id} обновлен: {old_balance} -> {user.balance_kopeks} коп (+{amount_kopeks})'
|
|
)
|
|
logger.info(f'✅ Создана транзакция ID: {transaction.id}')
|
|
|
|
if was_first_topup:
|
|
logger.info(f'Отмечен первый топап для пользователя {user_telegram_id}')
|
|
|
|
try:
|
|
from app.services.admin_notification_service import AdminNotificationService
|
|
|
|
notification_service = AdminNotificationService(self.bot)
|
|
await notification_service.send_balance_topup_notification(
|
|
user,
|
|
transaction,
|
|
old_balance,
|
|
topup_status=topup_status,
|
|
referrer_info=referrer_info,
|
|
subscription=subscription,
|
|
promo_group=promo_group,
|
|
db=session,
|
|
)
|
|
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}'
|
|
)
|
|
break
|
|
|
|
except Exception as e:
|
|
logger.error(f'⌘ Ошибка обработки успешного Tribute платежа: {e}', exc_info=True)
|
|
|
|
async def _handle_failed_payment(self, payment_data: dict[str, Any]):
|
|
try:
|
|
user_id = payment_data['user_id']
|
|
payment_id = payment_data['payment_id']
|
|
|
|
async for session in get_db():
|
|
transaction = await get_transaction_by_external_id(
|
|
session, f'donation_{payment_id}', PaymentMethod.TRIBUTE
|
|
)
|
|
|
|
if transaction:
|
|
transaction.description = f'{transaction.description} (платеж отклонен)'
|
|
await session.commit()
|
|
|
|
await self._send_failure_notification(user_id)
|
|
|
|
logger.info(f'Обработан неудачный Tribute платеж для пользователя {user_id}')
|
|
break
|
|
|
|
except Exception as e:
|
|
logger.error(f'Ошибка обработки неудачного Tribute платежа: {e}')
|
|
|
|
async def _handle_refund(self, refund_data: dict[str, Any]):
|
|
try:
|
|
user_id = refund_data['user_id']
|
|
amount_kopeks = refund_data['amount_kopeks']
|
|
payment_id = refund_data['payment_id']
|
|
|
|
async for session in get_db():
|
|
await create_transaction(
|
|
db=session,
|
|
user_id=user_id,
|
|
type=TransactionType.REFUND,
|
|
amount_kopeks=-amount_kopeks,
|
|
description=f'Возврат Tribute платежа {payment_id}',
|
|
payment_method=PaymentMethod.TRIBUTE,
|
|
external_id=f'refund_{payment_id}',
|
|
is_completed=True,
|
|
)
|
|
|
|
user = await get_user_by_telegram_id(session, user_id)
|
|
if user and user.balance_kopeks >= amount_kopeks:
|
|
user.balance_kopeks -= amount_kopeks
|
|
await session.commit()
|
|
|
|
await self._send_refund_notification(user_id, amount_kopeks)
|
|
|
|
logger.info(f'Обработан возврат Tribute: {amount_kopeks / 100}₽ для пользователя {user_id}')
|
|
break
|
|
|
|
except Exception as e:
|
|
logger.error(f'Ошибка обработки возврата Tribute: {e}')
|
|
|
|
async def _send_success_notification(self, user_id: int, amount_kopeks: int):
|
|
# Skip if no telegram_id (email-only user)
|
|
if not user_id:
|
|
logger.debug('Пропуск уведомления Tribute для пользователя без telegram_id')
|
|
return
|
|
|
|
try:
|
|
amount_rubles = amount_kopeks / 100
|
|
|
|
async for session in get_db():
|
|
user = await get_user_by_telegram_id(session, user_id)
|
|
break
|
|
|
|
# Сначала отправляем стандартное уведомление
|
|
payment_service = PaymentService(self.bot)
|
|
keyboard = await payment_service.build_topup_success_keyboard(user)
|
|
|
|
text = (
|
|
f'✅ **Платеж успешно получен!**\n\n'
|
|
f'💰 Сумма: {int(amount_rubles)} ₽\n'
|
|
f'💳 Способ оплаты: Tribute\n'
|
|
f'🎉 Средства зачислены на баланс!\n\n'
|
|
f'⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. '
|
|
f'Обязательно активируйте подписку отдельно!\n\n'
|
|
f'🔄 При наличии сохранённой корзины подписки и включенной автопокупке, '
|
|
f'подписка будет приобретена автоматически после пополнения баланса.\n\n'
|
|
f'Спасибо за оплату! 🙏'
|
|
)
|
|
|
|
await self.bot.send_message(user_id, text, reply_markup=keyboard, parse_mode='Markdown')
|
|
|
|
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
|
|
from app.services.user_cart_service import user_cart_service
|
|
|
|
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(
|
|
session,
|
|
user,
|
|
bot=self.bot,
|
|
)
|
|
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(
|
|
session, user, bot=self.bot, topup_amount=amount_kopeks
|
|
)
|
|
except Exception as auto_activate_error:
|
|
logger.error(
|
|
'Ошибка умной автоактивации для пользователя %s: %s',
|
|
user.id,
|
|
auto_activate_error,
|
|
exc_info=True,
|
|
)
|
|
|
|
# Отправляем уведомление только если его ещё не отправили и есть telegram_id
|
|
if has_saved_cart and self.bot and not activation_notification_sent and user_id:
|
|
# Если у пользователя есть сохраненная корзина,
|
|
# отправляем ему уведомление с кнопкой вернуться к оформлению
|
|
from aiogram import types
|
|
|
|
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='subscription_resume_checkout'
|
|
)
|
|
],
|
|
[types.InlineKeyboardButton(text='💰 Мой баланс', callback_data='menu_balance')],
|
|
[types.InlineKeyboardButton(text='🏠 Главное меню', callback_data='back_to_menu')],
|
|
]
|
|
)
|
|
|
|
await self.bot.send_message(
|
|
chat_id=user_id,
|
|
text=f'✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n'
|
|
f'⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. '
|
|
f'Обязательно активируйте подписку отдельно!\n\n{cart_message}',
|
|
reply_markup=keyboard,
|
|
)
|
|
logger.info(
|
|
'Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю %s',
|
|
user_id,
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f'Ошибка отправки уведомления об успешном платеже: {e}')
|
|
|
|
async def _send_failure_notification(self, user_id: int):
|
|
# Skip if no telegram_id (email-only user)
|
|
if not user_id:
|
|
logger.debug('Пропуск уведомления об ошибке Tribute для пользователя без telegram_id')
|
|
return
|
|
|
|
try:
|
|
text = (
|
|
'⌘ **Платеж не прошел**\n\n'
|
|
'К сожалению, ваш платеж через Tribute был отклонен.\n\n'
|
|
'Возможные причины:\n'
|
|
'• Недостаточно средств на карте\n'
|
|
'• Технические проблемы банка\n'
|
|
'• Превышен лимит операций\n\n'
|
|
'Попробуйте еще раз или обратитесь в поддержку.'
|
|
)
|
|
|
|
keyboard = InlineKeyboardMarkup(
|
|
inline_keyboard=[
|
|
[InlineKeyboardButton(text='🔄 Попробовать снова', callback_data='menu_balance')],
|
|
[InlineKeyboardButton(text='💬 Поддержка', callback_data='menu_support')],
|
|
]
|
|
)
|
|
|
|
await self.bot.send_message(user_id, text, reply_markup=keyboard, parse_mode='Markdown')
|
|
|
|
except Exception as e:
|
|
logger.error(f'Ошибка отправки уведомления о неудачном платеже: {e}')
|
|
|
|
async def _send_refund_notification(self, user_id: int, amount_kopeks: int):
|
|
# Skip if no telegram_id (email-only user)
|
|
if not user_id:
|
|
logger.debug('Пропуск уведомления о возврате Tribute для пользователя без telegram_id')
|
|
return
|
|
|
|
try:
|
|
amount_rubles = amount_kopeks / 100
|
|
|
|
text = (
|
|
f'🔄 **Возврат средств**\n\n'
|
|
f'💰 Сумма возврата: {int(amount_rubles)} ₽\n'
|
|
f'💳 Способ: Tribute\n\n'
|
|
f'Средства будут возвращены на вашу карту в течение 3-5 рабочих дней.\n\n'
|
|
f'Если у вас есть вопросы, обратитесь в поддержку.'
|
|
)
|
|
|
|
keyboard = InlineKeyboardMarkup(
|
|
inline_keyboard=[
|
|
[InlineKeyboardButton(text='💬 Поддержка', callback_data='menu_support')],
|
|
[InlineKeyboardButton(text='🏠 Главное меню', callback_data='back_to_menu')],
|
|
]
|
|
)
|
|
|
|
await self.bot.send_message(user_id, text, reply_markup=keyboard, parse_mode='Markdown')
|
|
|
|
except Exception as e:
|
|
logger.error(f'Ошибка отправки уведомления о возврате: {e}')
|
|
|
|
async def force_process_payment(
|
|
self,
|
|
payment_id: str,
|
|
user_id: int,
|
|
amount_kopeks: int,
|
|
description: str = 'Принудительная обработка Tribute платежа',
|
|
) -> bool:
|
|
try:
|
|
logger.info(
|
|
f'🔧 ПРИНУДИТЕЛЬНАЯ ОБРАБОТКА: payment_id={payment_id}, user_id={user_id}, amount={amount_kopeks}'
|
|
)
|
|
|
|
async for session in get_db():
|
|
user = await get_user_by_telegram_id(session, user_id)
|
|
if not user:
|
|
logger.error(f'⌘ Пользователь {user_id} не найден')
|
|
return False
|
|
|
|
external_id = f'force_donation_{payment_id}_{int(datetime.utcnow().timestamp())}'
|
|
|
|
await create_transaction(
|
|
db=session,
|
|
user_id=user.id,
|
|
type=TransactionType.DEPOSIT,
|
|
amount_kopeks=amount_kopeks,
|
|
description=description,
|
|
payment_method=PaymentMethod.TRIBUTE,
|
|
external_id=external_id,
|
|
is_completed=True,
|
|
)
|
|
|
|
old_balance = user.balance_kopeks
|
|
user.balance_kopeks += amount_kopeks
|
|
user.updated_at = datetime.utcnow()
|
|
|
|
await session.commit()
|
|
|
|
logger.info(f'💰 ПРИНУДИТЕЛЬНО обновлен баланс: {old_balance} -> {user.balance_kopeks} коп')
|
|
|
|
await self._send_success_notification(user_id, amount_kopeks)
|
|
|
|
logger.info(f'✅ Принудительно обработан платеж {payment_id}')
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f'⌘ Ошибка принудительной обработки: {e}', exc_info=True)
|
|
return False
|
|
|
|
async def get_payment_status(self, payment_id: str) -> dict[str, Any] | None:
|
|
return await self.tribute_api.get_payment_status(payment_id)
|
|
|
|
async def create_refund(
|
|
self, payment_id: str, amount_kopeks: int | None = None, reason: str = 'Возврат по запросу'
|
|
) -> dict[str, Any] | None:
|
|
return await self.tribute_api.refund_payment(payment_id, amount_kopeks, reason)
|