Files
remnawave-bedolaga-telegram…/app/services/donut_service.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

267 lines
9.2 KiB
Python

"""Сервис для работы с API Donut (Donut P2P, gw.donut.business)."""
import hashlib
import hmac
import json
from typing import Any
import aiohttp
import structlog
from app.config import settings
logger = structlog.get_logger(__name__)
class DonutAPIError(Exception):
"""Ошибка API Donut."""
def __init__(self, status_code: int, message: str, code: str | None = None) -> None:
self.status_code = status_code
self.message = message
self.api_code = code
super().__init__(f'Donut API error ({status_code}): {message}')
class DonutService:
"""Клиент для Donut P2P (gw.donut.business)."""
def __init__(self) -> None:
self._session: aiohttp.ClientSession | None = None
@property
def base_url(self) -> str:
return (settings.DONUT_BASE_URL or 'https://gw.donut.business').rstrip('/')
@property
def token(self) -> str:
return settings.DONUT_TOKEN or ''
@property
def secret(self) -> str:
return settings.DONUT_SECRET or ''
@property
def method_id(self) -> str | None:
value = (settings.DONUT_METHOD_ID or '').strip()
return value or None
async def _get_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=30),
)
return self._session
async def close(self) -> None:
if self._session and not self._session.closed:
await self._session.close()
self._session = None
@staticmethod
def _build_signature_string(parts: list[tuple[str, Any]]) -> str:
"""Собирает каноническую строку для подписи: имя=значение (без разделителя)."""
chunks: list[str] = []
for key, value in parts:
if value is None:
continue
if isinstance(value, bool):
chunks.append(f'{key}={"true" if value else "false"}')
else:
value_str = str(value)
if value_str == '':
continue
chunks.append(f'{key}={value_str}')
return ''.join(chunks)
def _hmac_hex(self, message: str) -> str:
"""HMAC-SHA256 в hex."""
return hmac.new(
self.secret.encode('utf-8'),
msg=message.encode('utf-8'),
digestmod=hashlib.sha256,
).hexdigest()
def _sign_payin(self, payload: dict[str, Any]) -> str:
amount = payload['amount']
customer = payload['customer']
parts: list[tuple[str, Any]] = [
('token', payload['token']),
('order_id', payload['order_id']),
('amount.value', amount['value']),
('amount.currency', amount['currency']),
('customer.id', customer['id']),
('redirect', payload['redirect']),
]
return self._hmac_hex(self._build_signature_string(parts))
def _sign_status(self, payload: dict[str, Any]) -> str:
parts: list[tuple[str, Any]] = [
('token', payload['token']),
('transaction_id', payload['transaction_id']),
]
return self._hmac_hex(self._build_signature_string(parts))
def _sign_balance(self, payload: dict[str, Any]) -> str:
parts: list[tuple[str, Any]] = [
('token', payload['token']),
]
return self._hmac_hex(self._build_signature_string(parts))
@staticmethod
def _format_amount(amount_rubles: float) -> str:
"""Сумма строго '0.00' с точкой (требование Donut P2P)."""
return f'{float(amount_rubles):.2f}'
async def _post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
url = f'{self.base_url}/{path.lstrip("/")}'
body = json.dumps(payload, separators=(',', ':'), ensure_ascii=False)
try:
session = await self._get_session()
async with session.post(
url,
data=body,
headers={'Content-Type': 'application/json'},
) as response:
data = await response.json(content_type=None)
return data if isinstance(data, dict) else {'_raw': data}
except aiohttp.ClientError as error:
logger.exception('Donut API connection error', url=url, error=error)
raise
async def create_payment(
self,
*,
amount_rubles: float,
order_id: str,
customer_id: str,
method_description: str,
customer_email: str | None = None,
customer_phone: str | None = None,
callback_url: str | None = None,
return_url: str | None = None,
receipt: str | None = None,
redirect: bool = True,
) -> dict[str, Any]:
"""Создаёт платёж (PayIn) через Donut P2P.
POST /p2p_payin
"""
payload: dict[str, Any] = {
'token': self.token,
'order_id': order_id,
'amount': {
'value': self._format_amount(amount_rubles),
'currency': (settings.DONUT_CURRENCY or 'RUB').upper(),
},
'customer': {'id': str(customer_id)},
'redirect': 'true' if redirect else 'false',
'description': method_description,
}
if customer_email:
payload['customer']['email'] = customer_email
if customer_phone:
payload['customer']['phone'] = customer_phone
if self.method_id:
payload['method_id'] = self.method_id
if callback_url:
payload['callback_url'] = callback_url
if return_url:
payload['return_url'] = return_url
if receipt:
payload['receipt'] = receipt[:255]
payload['signature'] = self._sign_payin(payload)
logger.info(
'Donut API create_payment',
order_id=order_id,
amount_rubles=amount_rubles,
description=method_description,
)
data = await self._post('/p2p_payin', payload)
status_obj = (data.get('status') or {}) if isinstance(data, dict) else {}
status_type = status_obj.get('type')
if status_type in ('processing', 'success', 'created'):
logger.info(
'Donut API payment created',
order_id=order_id,
transaction_id=data.get('transaction_id'),
status_type=status_type,
)
return data
error_code = status_obj.get('error_code') or '0'
error_msg = status_obj.get('error_description') or status_obj.get('message') or 'Unknown error'
logger.error(
'Donut create_payment error',
error_code=error_code,
error_msg=error_msg,
response_data=data,
)
raise DonutAPIError(200, error_msg, error_code)
async def check_payment(self, *, transaction_id: str) -> dict[str, Any]:
"""Получает статус платежа.
POST /p2p_status
"""
payload: dict[str, Any] = {
'token': self.token,
'transaction_id': str(transaction_id),
}
payload['signature'] = self._sign_status(payload)
logger.info('Donut check_payment', transaction_id=transaction_id)
return await self._post('/p2p_status', payload)
async def get_balance(self) -> dict[str, Any]:
"""Получает баланс продавца.
POST /p2p_balance
"""
payload: dict[str, Any] = {'token': self.token}
payload['signature'] = self._sign_balance(payload)
return await self._post('/p2p_balance', payload)
def verify_callback_signature(self, payload: dict[str, Any]) -> bool:
"""Верификация подписи callback (HMAC-SHA256, hex)."""
try:
received = (payload.get('signature') or '').strip()
if not received:
logger.warning('Donut callback: отсутствует signature')
return False
amount = payload.get('amount') or {}
status_obj = payload.get('status') or {}
parts: list[tuple[str, Any]] = [
('token', payload.get('token')),
('transaction_id', payload.get('transaction_id')),
('order_id', payload.get('order_id')),
('amount.value', amount.get('value')),
('amount.currency', amount.get('currency')),
('recalculated', payload.get('recalculated')),
('status.type', status_obj.get('type')),
]
expected = self._hmac_hex(self._build_signature_string(parts))
if not hmac.compare_digest(expected.lower(), received.lower()):
logger.warning(
'Donut callback: invalid signature',
expected_prefix=expected[:8],
received_prefix=received[:8],
)
return False
return True
except Exception as error:
logger.error('Donut callback verify error', error=error)
return False
# Singleton instance
donut_service = DonutService()