Files
remnawave-bedolaga-telegram…/app/services/payment/donut.py
T
Fringg f321ded9c0 feat: integrate Jupiter (FPGate P2P) and Donut payment providers
- Jupiter: SBP via app.juppiter.tech (FPGate P2P v2.1)
- Donut: CARD/SBP/SBP_QR via gw.donut.business (Donut P2P)
- HMAC-SHA256 signing verified against spec reference vectors
- Sticky terminal-status guard in callback (amount_mismatch/declined/cancelled
  cannot be re-credited by replayed webhook)
- Mirrors existing Antilopay/Etoplatezhi mixin pattern: service, mixin, CRUD,
  Alembic migration, handlers, keyboards, webhook, cabinet route, status mapping
- Adds JUPITER and DONUT settings categories with title/description/prefix
- Backfills missing ANTILOPAY and ETOPLATEZHI category metadata
2026-05-04 19:36:22 +03:00

507 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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