17732a0370
CI ruff format --check failed on 13 files. Applied ruff format to bring them in line with project formatting (line wrapping, trailing commas, quote consistency). No functional changes.
501 lines
19 KiB
Python
501 lines
19 KiB
Python
"""Mixin для интеграции с Donut P2P (gw.donut.business)."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import uuid
|
||
from datetime import UTC, datetime, timedelta
|
||
from importlib import import_module
|
||
from typing import Any
|
||
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.config import settings
|
||
from app.database.models import PaymentMethod, TransactionType
|
||
from app.services.donut_service import donut_service
|
||
from app.utils.payment_logger import payment_logger as logger
|
||
from app.utils.user_utils import format_referrer_info
|
||
|
||
|
||
# Маппинг description при PayIn (Donut) <-> наш sub-method id
|
||
DONUT_METHOD_DESCRIPTIONS: dict[str | None, str] = {
|
||
None: 'CARD',
|
||
'card': 'CARD',
|
||
'sbp': 'SBP',
|
||
'sbp_qr': 'SBP_QR',
|
||
}
|
||
|
||
|
||
# Маппинг статусов Donut -> internal
|
||
DONUT_STATUS_MAP: dict[str, tuple[str, bool]] = {
|
||
'created': ('pending', False),
|
||
'processing': ('pending', False),
|
||
'success': ('success', True),
|
||
'cancelled': ('cancelled', False),
|
||
'error': ('error', False),
|
||
}
|
||
|
||
|
||
class DonutPaymentMixin:
|
||
"""Mixin для работы с платежами Donut."""
|
||
|
||
async def create_donut_payment(
|
||
self,
|
||
db: AsyncSession,
|
||
*,
|
||
user_id: int | None,
|
||
amount_kopeks: int,
|
||
description: str = 'Пополнение баланса',
|
||
email: str | None = None,
|
||
language: str = 'ru',
|
||
payment_method_type: str | None = None,
|
||
return_url: str | None = None,
|
||
) -> dict[str, Any] | None:
|
||
"""Создаёт платёж Donut."""
|
||
if not settings.is_donut_enabled():
|
||
logger.error('Donut не настроен')
|
||
return None
|
||
|
||
if amount_kopeks < settings.DONUT_MIN_AMOUNT_KOPEKS:
|
||
logger.warning(
|
||
'Donut: сумма меньше минимальной',
|
||
amount_kopeks=amount_kopeks,
|
||
DONUT_MIN_AMOUNT_KOPEKS=settings.DONUT_MIN_AMOUNT_KOPEKS,
|
||
)
|
||
return None
|
||
|
||
if amount_kopeks > settings.DONUT_MAX_AMOUNT_KOPEKS:
|
||
logger.warning(
|
||
'Donut: сумма больше максимальной',
|
||
amount_kopeks=amount_kopeks,
|
||
DONUT_MAX_AMOUNT_KOPEKS=settings.DONUT_MAX_AMOUNT_KOPEKS,
|
||
)
|
||
return None
|
||
|
||
payment_module = import_module('app.services.payment_service')
|
||
if user_id is not None:
|
||
user = await payment_module.get_user_by_id(db, user_id)
|
||
tg_id = user.telegram_id if user else user_id
|
||
else:
|
||
user = None
|
||
tg_id = 'guest'
|
||
|
||
order_id = f'dnt{tg_id}_{uuid.uuid4().hex[:6]}'
|
||
amount_rubles = amount_kopeks / 100
|
||
currency = settings.DONUT_CURRENCY
|
||
|
||
method_key = (payment_method_type or '').lower() or None
|
||
method_description = DONUT_METHOD_DESCRIPTIONS.get(method_key, 'CARD')
|
||
|
||
metadata = {
|
||
'user_id': user_id,
|
||
'amount_kopeks': amount_kopeks,
|
||
'description': description,
|
||
'language': language,
|
||
'type': 'balance_topup',
|
||
'payment_method_type': method_key,
|
||
'donut_description': method_description,
|
||
}
|
||
|
||
try:
|
||
callback_url = self._build_donut_callback_url()
|
||
customer_id = str(tg_id) if tg_id != 'guest' else f'guest-{order_id[-6:]}'
|
||
actual_return_url = return_url or settings.DONUT_RETURN_URL
|
||
|
||
api_result = await donut_service.create_payment(
|
||
amount_rubles=amount_rubles,
|
||
order_id=order_id,
|
||
customer_id=customer_id,
|
||
method_description=method_description,
|
||
customer_email=email,
|
||
customer_phone=getattr(user, 'phone', None) if user else None,
|
||
callback_url=callback_url,
|
||
return_url=actual_return_url,
|
||
redirect=True,
|
||
)
|
||
|
||
transaction_id = api_result.get('transaction_id')
|
||
details = api_result.get('details') or {}
|
||
payment_url = api_result.get('redirect_url') or details.get('qrcode_url') or actual_return_url
|
||
|
||
logger.info(
|
||
'Donut: получен ответ API',
|
||
order_id=order_id,
|
||
transaction_id=transaction_id,
|
||
payment_url=payment_url,
|
||
method_description=method_description,
|
||
)
|
||
|
||
lifetime = settings.DONUT_PAYMENT_LIFETIME_MINUTES
|
||
expires_at = datetime.now(UTC) + timedelta(minutes=lifetime)
|
||
|
||
donut_crud = import_module('app.database.crud.donut')
|
||
local_payment = await donut_crud.create_donut_payment(
|
||
db=db,
|
||
user_id=user_id,
|
||
order_id=order_id,
|
||
amount_kopeks=amount_kopeks,
|
||
currency=currency,
|
||
description=description,
|
||
payment_url=payment_url,
|
||
payment_method=method_key,
|
||
donut_transaction_id=str(transaction_id) if transaction_id else None,
|
||
expires_at=expires_at,
|
||
metadata_json=metadata,
|
||
)
|
||
|
||
logger.info(
|
||
'Donut: создан платеж',
|
||
order_id=order_id,
|
||
user_id=user_id,
|
||
amount_rubles=amount_rubles,
|
||
currency=currency,
|
||
)
|
||
|
||
return {
|
||
'order_id': order_id,
|
||
'amount_kopeks': amount_kopeks,
|
||
'amount_rubles': amount_rubles,
|
||
'currency': currency,
|
||
'payment_url': payment_url,
|
||
'payment_id': str(transaction_id) if transaction_id else None,
|
||
'expires_at': expires_at.isoformat(),
|
||
'local_payment_id': local_payment.id,
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.exception('Donut: ошибка создания платежа', error=e)
|
||
return None
|
||
|
||
@staticmethod
|
||
def _build_donut_callback_url() -> str | None:
|
||
"""Собирает абсолютный callback URL для Donut."""
|
||
webhook_path = settings.DONUT_WEBHOOK_PATH or '/donut-webhook'
|
||
base = (
|
||
getattr(settings, 'WEBHOOK_URL', None)
|
||
or getattr(settings, 'WEB_API_BASE_URL', None)
|
||
or getattr(settings, 'CABINET_URL', None)
|
||
)
|
||
if not base:
|
||
return None
|
||
return f'{base.rstrip("/")}{webhook_path if webhook_path.startswith("/") else "/" + webhook_path}'
|
||
|
||
async def process_donut_callback(
|
||
self,
|
||
db: AsyncSession,
|
||
payload: dict[str, Any],
|
||
) -> bool:
|
||
"""Обрабатывает callback от Donut (подпись уже проверена в webserver)."""
|
||
try:
|
||
our_order_id = payload.get('order_id')
|
||
donut_transaction_id = payload.get('transaction_id')
|
||
status_obj = payload.get('status') or {}
|
||
donut_status = (status_obj.get('type') or '').strip().lower()
|
||
|
||
if not our_order_id or not donut_status:
|
||
logger.warning('Donut callback: отсутствуют обязательные поля', payload=payload)
|
||
return False
|
||
|
||
donut_crud = import_module('app.database.crud.donut')
|
||
payment = await donut_crud.get_donut_payment_by_order_id(db, our_order_id)
|
||
if not payment:
|
||
logger.warning('Donut callback: платеж не найден', order_id=our_order_id)
|
||
return False
|
||
|
||
locked = await donut_crud.get_donut_payment_by_id_for_update(db, payment.id)
|
||
if not locked:
|
||
logger.error('Donut: не удалось заблокировать платёж', payment_id=payment.id)
|
||
return False
|
||
payment = locked
|
||
|
||
if payment.is_paid:
|
||
logger.info('Donut callback: платеж уже обработан', order_id=payment.order_id)
|
||
return True
|
||
|
||
# Терминальные неуспешные статусы стики — провайдер не должен иметь возможность
|
||
# «починить» отклонённый/несовпавший платёж повторным callback'ом.
|
||
if payment.status in {'amount_mismatch', 'cancelled', 'error', 'expired'}:
|
||
logger.warning(
|
||
'Donut callback: платёж в терминальном неуспешном статусе, игнорируется',
|
||
order_id=payment.order_id,
|
||
current_status=payment.status,
|
||
incoming_status=donut_status,
|
||
)
|
||
return True
|
||
|
||
internal_status, is_paid = DONUT_STATUS_MAP.get(donut_status, ('pending', False))
|
||
|
||
callback_payload = {
|
||
'donut_transaction_id': donut_transaction_id,
|
||
'status_type': donut_status,
|
||
'amount': payload.get('amount'),
|
||
'recalculated': payload.get('recalculated'),
|
||
'timestamp': payload.get('timestamp'),
|
||
}
|
||
|
||
if is_paid:
|
||
amount_obj = payload.get('amount') or {}
|
||
received_value = amount_obj.get('value')
|
||
if received_value is not None:
|
||
try:
|
||
received_kopeks = round(float(received_value) * 100)
|
||
except (TypeError, ValueError):
|
||
received_kopeks = None
|
||
if received_kopeks is not None and abs(received_kopeks - payment.amount_kopeks) > 1:
|
||
logger.error(
|
||
'Donut amount mismatch',
|
||
expected_kopeks=payment.amount_kopeks,
|
||
received_kopeks=received_kopeks,
|
||
order_id=payment.order_id,
|
||
)
|
||
await donut_crud.update_donut_payment_status(
|
||
db=db,
|
||
payment=payment,
|
||
status='amount_mismatch',
|
||
is_paid=False,
|
||
callback_payload=callback_payload,
|
||
)
|
||
return False
|
||
|
||
if is_paid:
|
||
payment.status = internal_status
|
||
payment.is_paid = True
|
||
payment.paid_at = datetime.now(UTC)
|
||
payment.donut_transaction_id = (
|
||
str(donut_transaction_id) if donut_transaction_id else payment.donut_transaction_id
|
||
)
|
||
payment.callback_payload = callback_payload
|
||
payment.updated_at = datetime.now(UTC)
|
||
await db.flush()
|
||
return await self._finalize_donut_payment(db, payment, trigger='webhook')
|
||
|
||
payment = await donut_crud.update_donut_payment_status(
|
||
db=db,
|
||
payment=payment,
|
||
status=internal_status,
|
||
is_paid=False,
|
||
callback_payload=callback_payload,
|
||
)
|
||
return True
|
||
|
||
except Exception as e:
|
||
logger.exception('Donut callback: ошибка обработки', error=e)
|
||
return False
|
||
|
||
async def _finalize_donut_payment(
|
||
self,
|
||
db: AsyncSession,
|
||
payment: Any,
|
||
*,
|
||
trigger: str,
|
||
) -> bool:
|
||
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления.
|
||
|
||
FOR UPDATE lock уже взят вызывающим.
|
||
"""
|
||
payment_module = import_module('app.services.payment_service')
|
||
donut_crud = import_module('app.database.crud.donut')
|
||
|
||
if payment.transaction_id:
|
||
logger.info(
|
||
'Donut платеж уже связан с транзакцией',
|
||
order_id=payment.order_id,
|
||
transaction_id=payment.transaction_id,
|
||
trigger=trigger,
|
||
)
|
||
return True
|
||
|
||
metadata = dict(getattr(payment, 'metadata_json', {}) or {})
|
||
|
||
from app.services.payment.common import try_fulfill_guest_purchase
|
||
|
||
guest_result = await try_fulfill_guest_purchase(
|
||
db,
|
||
metadata=metadata,
|
||
payment_amount_kopeks=payment.amount_kopeks,
|
||
provider_payment_id=payment.order_id,
|
||
provider_name='donut',
|
||
)
|
||
if guest_result is not None:
|
||
return True
|
||
|
||
if not payment.is_paid:
|
||
payment.status = 'success'
|
||
payment.is_paid = True
|
||
payment.paid_at = datetime.now(UTC)
|
||
payment.updated_at = datetime.now(UTC)
|
||
|
||
balance_already_credited = bool(metadata.get('balance_credited'))
|
||
|
||
user = await payment_module.get_user_by_id(db, payment.user_id)
|
||
if not user:
|
||
logger.error('Пользователь не найден для Donut', user_id=payment.user_id)
|
||
return False
|
||
|
||
await db.refresh(user, attribute_names=['promo_group', 'user_promo_groups'])
|
||
for user_promo_group in getattr(user, 'user_promo_groups', []):
|
||
await db.refresh(user_promo_group, attribute_names=['promo_group'])
|
||
|
||
promo_group = user.get_primary_promo_group()
|
||
subscription = getattr(user, 'subscription', None)
|
||
referrer_info = format_referrer_info(user)
|
||
|
||
transaction_external_id = payment.order_id
|
||
|
||
existing_transaction = None
|
||
if transaction_external_id:
|
||
existing_transaction = await payment_module.get_transaction_by_external_id(
|
||
db,
|
||
transaction_external_id,
|
||
PaymentMethod.DONUT,
|
||
)
|
||
|
||
display_name = settings.get_donut_display_name()
|
||
description = f'Пополнение через {display_name}'
|
||
|
||
transaction = existing_transaction
|
||
created_transaction = False
|
||
|
||
if not transaction:
|
||
transaction = await payment_module.create_transaction(
|
||
db,
|
||
user_id=payment.user_id,
|
||
type=TransactionType.DEPOSIT,
|
||
amount_kopeks=payment.amount_kopeks,
|
||
description=description,
|
||
payment_method=PaymentMethod.DONUT,
|
||
external_id=transaction_external_id,
|
||
is_completed=True,
|
||
created_at=getattr(payment, 'created_at', None),
|
||
commit=False,
|
||
)
|
||
created_transaction = True
|
||
|
||
await donut_crud.link_donut_payment_to_transaction(db, payment=payment, transaction_id=transaction.id)
|
||
|
||
should_credit_balance = created_transaction or not balance_already_credited
|
||
|
||
if not should_credit_balance:
|
||
logger.info('Donut платеж уже зачислил баланс ранее', order_id=payment.order_id)
|
||
return True
|
||
|
||
from app.database.crud.user import lock_user_for_update
|
||
|
||
user = await lock_user_for_update(db, user)
|
||
|
||
old_balance = user.balance_kopeks
|
||
was_first_topup = not user.has_made_first_topup
|
||
|
||
user.balance_kopeks += payment.amount_kopeks
|
||
user.updated_at = datetime.now(UTC)
|
||
await db.commit()
|
||
await db.refresh(user)
|
||
|
||
from app.database.crud.transaction import emit_transaction_side_effects
|
||
|
||
await emit_transaction_side_effects(
|
||
db,
|
||
transaction,
|
||
amount_kopeks=payment.amount_kopeks,
|
||
user_id=payment.user_id,
|
||
type=TransactionType.DEPOSIT,
|
||
payment_method=PaymentMethod.DONUT,
|
||
external_id=transaction_external_id,
|
||
)
|
||
|
||
topup_status = '\U0001f195 Первое пополнение' if was_first_topup else '\U0001f504 Пополнение'
|
||
|
||
try:
|
||
from app.services.referral_service import process_referral_topup
|
||
|
||
await process_referral_topup(
|
||
db,
|
||
user.id,
|
||
payment.amount_kopeks,
|
||
getattr(self, 'bot', None),
|
||
)
|
||
except Exception as error:
|
||
logger.error('Ошибка обработки реферального пополнения Donut', error=error)
|
||
|
||
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
|
||
user.has_made_first_topup = True
|
||
await db.commit()
|
||
await db.refresh(user)
|
||
|
||
if getattr(self, 'bot', None):
|
||
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=db,
|
||
)
|
||
except Exception as error:
|
||
logger.error('Ошибка отправки админ уведомления Donut', error=error)
|
||
|
||
if getattr(self, 'bot', None) and user.telegram_id:
|
||
try:
|
||
keyboard = await self.build_topup_success_keyboard(user)
|
||
await self.bot.send_message(
|
||
user.telegram_id,
|
||
(
|
||
'✅ <b>Пополнение успешно!</b>\n\n'
|
||
f'\U0001f4b0 Сумма: {settings.format_price(payment.amount_kopeks)}\n'
|
||
f'\U0001f4b3 Способ: {display_name}\n'
|
||
f'\U0001f194 Транзакция: {transaction.id}\n\n'
|
||
'Баланс пополнен автоматически!'
|
||
),
|
||
parse_mode='HTML',
|
||
reply_markup=keyboard,
|
||
)
|
||
except Exception as error:
|
||
logger.error('Ошибка отправки уведомления пользователю Donut', error=error)
|
||
|
||
try:
|
||
from app.services.payment.common import send_cart_notification_after_topup
|
||
|
||
await send_cart_notification_after_topup(user, payment.amount_kopeks, db, getattr(self, 'bot', None))
|
||
except Exception as error:
|
||
logger.error(
|
||
'Ошибка при работе с сохраненной корзиной для пользователя',
|
||
user_id=payment.user_id,
|
||
error=error,
|
||
exc_info=True,
|
||
)
|
||
|
||
metadata['balance_change'] = {
|
||
'old_balance': old_balance,
|
||
'new_balance': user.balance_kopeks,
|
||
'credited_at': datetime.now(UTC).isoformat(),
|
||
}
|
||
metadata['balance_credited'] = True
|
||
payment.metadata_json = metadata
|
||
await db.commit()
|
||
|
||
logger.info(
|
||
'Обработан Donut платеж',
|
||
order_id=payment.order_id,
|
||
user_id=payment.user_id,
|
||
trigger=trigger,
|
||
)
|
||
|
||
return True
|
||
|
||
async def check_donut_payment_status(
|
||
self,
|
||
db: AsyncSession,
|
||
transaction_id: str,
|
||
) -> dict[str, Any] | None:
|
||
"""Запрос статуса платежа через API Donut."""
|
||
try:
|
||
return await donut_service.check_payment(transaction_id=transaction_id)
|
||
except Exception as e:
|
||
logger.error('Donut: ошибка проверки статуса', transaction_id=transaction_id, error=e)
|
||
return None
|