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
This commit is contained in:
Fringg
2026-05-04 19:36:22 +03:00
parent 3fce64858c
commit f321ded9c0
19 changed files with 3199 additions and 0 deletions
+86
View File
@@ -914,6 +914,68 @@ async def create_topup(
detail='Failed to create AuraPay payment',
)
elif request.payment_method == 'jupiter':
if not settings.is_jupiter_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Jupiter payment method is unavailable',
)
payment_service = PaymentService()
payment_method_type = request.payment_option or None
result = await payment_service.create_jupiter_payment(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
email=getattr(user, 'email', None),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
payment_method_type=payment_method_type,
return_url=cabinet_success_url,
)
if result and result.get('payment_url'):
payment_url = result.get('payment_url')
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to create Jupiter payment',
)
elif request.payment_method == 'donut':
if not settings.is_donut_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Donut payment method is unavailable',
)
payment_service = PaymentService()
payment_method_type = request.payment_option or None
result = await payment_service.create_donut_payment(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(
request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id
),
email=getattr(user, 'email', None),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
payment_method_type=payment_method_type,
return_url=cabinet_success_url,
)
if result and result.get('payment_url'):
payment_url = result.get('payment_url')
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to create Donut payment',
)
else:
# For other payment methods, redirect to bot
raise HTTPException(
@@ -1065,6 +1127,30 @@ def _get_status_info(record: PendingPayment) -> tuple[str, str]:
}
return mapping.get(status, ('', 'Неизвестно'))
if record.method == PaymentMethod.JUPITER:
mapping = {
'pending': ('', 'Ожидает оплаты'),
'processing': ('', 'Обрабатывается'),
'success': ('', 'Оплачено'),
'cancelled': ('', 'Отменено'),
'declined': ('', 'Отклонено'),
'error': ('', 'Ошибка'),
'amount_mismatch': ('⚠️', 'Несовпадение суммы'),
}
return mapping.get(status, ('', 'Неизвестно'))
if record.method == PaymentMethod.DONUT:
mapping = {
'pending': ('', 'Ожидает оплаты'),
'created': ('', 'Создано'),
'processing': ('', 'Обрабатывается'),
'success': ('', 'Оплачено'),
'cancelled': ('', 'Отменено'),
'error': ('', 'Ошибка'),
'amount_mismatch': ('⚠️', 'Несовпадение суммы'),
}
return mapping.get(status, ('', 'Неизвестно'))
return '', 'Неизвестно'
+109
View File
@@ -712,6 +712,47 @@ class Settings(BaseSettings):
ANTILOPAY_SBERPAY_ENABLED: bool = False
ANTILOPAY_SBERPAY_DISPLAY_NAME: str = 'SberPay (Antilopay)'
# Jupiter (FPGate P2P v2.1, app.juppiter.tech)
JUPITER_ENABLED: bool = False
JUPITER_TOKEN: str | None = None
JUPITER_SECRET: str | None = None
JUPITER_BASE_URL: str = 'https://app.juppiter.tech'
JUPITER_METHOD_ID: str | None = None
JUPITER_METHOD_DESCRIPTION: str = 'SBP'
JUPITER_DISPLAY_NAME: str = 'Jupiter'
JUPITER_CURRENCY: str = 'RUB'
JUPITER_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
JUPITER_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
JUPITER_WEBHOOK_PATH: str = '/jupiter-webhook'
JUPITER_RETURN_URL: str | None = None
JUPITER_PAYMENT_LIFETIME_MINUTES: int = 60
JUPITER_FALLBACK_EMAIL: str = 'user@vpn.bot'
JUPITER_FALLBACK_PHONE: str = '0000000000'
JUPITER_FALLBACK_NAME: str = 'User'
JUPITER_SBP_ENABLED: bool = False
JUPITER_SBP_DISPLAY_NAME: str = 'СБП (Jupiter)'
# Donut (Donut P2P, gw.donut.business)
DONUT_ENABLED: bool = False
DONUT_TOKEN: str | None = None
DONUT_SECRET: str | None = None
DONUT_BASE_URL: str = 'https://gw.donut.business'
DONUT_METHOD_ID: str | None = None
DONUT_DISPLAY_NAME: str = 'Donut'
DONUT_CURRENCY: str = 'RUB'
DONUT_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
DONUT_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
DONUT_WEBHOOK_PATH: str = '/donut-webhook'
DONUT_RETURN_URL: str | None = None
DONUT_PAYMENT_LIFETIME_MINUTES: int = 60
# Sub-методы Donut (description в PayIn запросе)
DONUT_CARD_ENABLED: bool = False
DONUT_CARD_DISPLAY_NAME: str = 'Карта (Donut)'
DONUT_SBP_ENABLED: bool = False
DONUT_SBP_DISPLAY_NAME: str = 'СБП (Donut)'
DONUT_SBP_QR_ENABLED: bool = False
DONUT_SBP_QR_DISPLAY_NAME: str = 'СБП QR (Donut)'
# Etoplatezhi (paymentpage.etoplatezhi.ru)
ETOPLATEZHI_ENABLED: bool = False
ETOPLATEZHI_PROJECT_ID: int | None = None
@@ -2234,6 +2275,74 @@ class Settings(BaseSettings):
def get_antilopay_sberpay_display_name_html(self) -> str:
return html.escape(self.get_antilopay_sberpay_display_name())
def is_jupiter_enabled(self) -> bool:
return (
self.JUPITER_ENABLED
and self.JUPITER_TOKEN is not None
and self.JUPITER_SECRET is not None
)
def get_jupiter_display_name(self) -> str:
name = (self.JUPITER_DISPLAY_NAME or '').strip()
return name if name else 'Jupiter'
def get_jupiter_display_name_html(self) -> str:
return html.escape(self.get_jupiter_display_name())
def is_jupiter_sbp_enabled(self) -> bool:
return self.JUPITER_SBP_ENABLED and self.is_jupiter_enabled()
def get_jupiter_sbp_display_name(self) -> str:
name = (self.JUPITER_SBP_DISPLAY_NAME or '').strip()
return name or 'СБП (Jupiter)'
def get_jupiter_sbp_display_name_html(self) -> str:
return html.escape(self.get_jupiter_sbp_display_name())
def is_donut_enabled(self) -> bool:
return (
self.DONUT_ENABLED
and self.DONUT_TOKEN is not None
and self.DONUT_SECRET is not None
)
def get_donut_display_name(self) -> str:
name = (self.DONUT_DISPLAY_NAME or '').strip()
return name if name else 'Donut'
def get_donut_display_name_html(self) -> str:
return html.escape(self.get_donut_display_name())
def is_donut_card_enabled(self) -> bool:
return self.DONUT_CARD_ENABLED and self.is_donut_enabled()
def get_donut_card_display_name(self) -> str:
name = (self.DONUT_CARD_DISPLAY_NAME or '').strip()
return name or 'Карта (Donut)'
def get_donut_card_display_name_html(self) -> str:
return html.escape(self.get_donut_card_display_name())
def is_donut_sbp_enabled(self) -> bool:
return self.DONUT_SBP_ENABLED and self.is_donut_enabled()
def get_donut_sbp_display_name(self) -> str:
name = (self.DONUT_SBP_DISPLAY_NAME or '').strip()
return name or 'СБП (Donut)'
def get_donut_sbp_display_name_html(self) -> str:
return html.escape(self.get_donut_sbp_display_name())
def is_donut_sbp_qr_enabled(self) -> bool:
return self.DONUT_SBP_QR_ENABLED and self.is_donut_enabled()
def get_donut_sbp_qr_display_name(self) -> str:
name = (self.DONUT_SBP_QR_DISPLAY_NAME or '').strip()
return name or 'СБП QR (Donut)'
def get_donut_sbp_qr_display_name_html(self) -> str:
return html.escape(self.get_donut_sbp_qr_display_name())
def is_etoplatezhi_enabled(self) -> bool:
return (
self.ETOPLATEZHI_ENABLED
+159
View File
@@ -0,0 +1,159 @@
"""CRUD операции для платежей Donut (Donut P2P)."""
from datetime import UTC, datetime
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import DonutPayment
logger = structlog.get_logger(__name__)
async def create_donut_payment(
db: AsyncSession,
*,
user_id: int | None,
order_id: str,
amount_kopeks: int,
currency: str = 'RUB',
description: str | None = None,
payment_url: str | None = None,
payment_method: str | None = None,
donut_transaction_id: str | None = None,
expires_at: datetime | None = None,
metadata_json: dict | None = None,
) -> DonutPayment:
"""Создаёт запись о платеже Donut."""
payment = DonutPayment(
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
payment_method=payment_method,
donut_transaction_id=donut_transaction_id,
expires_at=expires_at,
metadata_json=metadata_json,
status='pending',
is_paid=False,
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info('Создан платеж Donut', order_id=order_id, user_id=user_id)
return payment
async def get_donut_payment_by_order_id(db: AsyncSession, order_id: str) -> DonutPayment | None:
"""Получает платеж по order_id (internal)."""
result = await db.execute(select(DonutPayment).where(DonutPayment.order_id == order_id))
return result.scalar_one_or_none()
async def get_donut_payment_by_invoice_id(
db: AsyncSession, donut_transaction_id: str
) -> DonutPayment | None:
"""Получает платёж по transaction_id, выданному Donut."""
result = await db.execute(
select(DonutPayment).where(DonutPayment.donut_transaction_id == donut_transaction_id)
)
return result.scalar_one_or_none()
async def get_donut_payment_by_id(db: AsyncSession, payment_id: int) -> DonutPayment | None:
"""Получает платеж по локальному ID."""
result = await db.execute(select(DonutPayment).where(DonutPayment.id == payment_id))
return result.scalar_one_or_none()
async def get_donut_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> DonutPayment | None:
"""Получает платёж с блокировкой FOR UPDATE."""
result = await db.execute(
select(DonutPayment)
.where(DonutPayment.id == payment_id)
.with_for_update()
.execution_options(populate_existing=True)
)
return result.scalar_one_or_none()
async def update_donut_payment_status(
db: AsyncSession,
payment: DonutPayment,
*,
status: str,
is_paid: bool | None = None,
donut_transaction_id: str | None = None,
payment_method: str | None = None,
callback_payload: dict | None = None,
transaction_id: int | None = None,
) -> DonutPayment:
"""Обновляет статус платежа."""
payment.status = status
payment.updated_at = datetime.now(UTC)
if is_paid is not None:
payment.is_paid = is_paid
if is_paid:
payment.paid_at = datetime.now(UTC)
if donut_transaction_id is not None:
payment.donut_transaction_id = donut_transaction_id
if payment_method is not None:
payment.payment_method = payment_method
if callback_payload is not None:
payment.callback_payload = callback_payload
if transaction_id is not None:
payment.transaction_id = transaction_id
await db.commit()
await db.refresh(payment)
logger.info(
'Обновлён статус платежа Donut',
order_id=payment.order_id,
status=status,
is_paid=payment.is_paid,
)
return payment
async def get_pending_donut_payments(db: AsyncSession, user_id: int) -> list[DonutPayment]:
"""Возвращает незавершённые платежи пользователя."""
result = await db.execute(
select(DonutPayment).where(
DonutPayment.user_id == user_id,
DonutPayment.status == 'pending',
DonutPayment.is_paid == False,
)
)
return list(result.scalars().all())
async def get_expired_pending_donut_payments(db: AsyncSession) -> list[DonutPayment]:
"""Возвращает просроченные платежи в статусе pending."""
now = datetime.now(UTC)
result = await db.execute(
select(DonutPayment).where(
DonutPayment.status == 'pending',
DonutPayment.is_paid == False,
DonutPayment.expires_at < now,
)
)
return list(result.scalars().all())
async def link_donut_payment_to_transaction(
db: AsyncSession,
*,
payment: DonutPayment,
transaction_id: int,
) -> DonutPayment:
"""Связывает платёж с транзакцией."""
payment.transaction_id = transaction_id
payment.updated_at = datetime.now(UTC)
await db.flush()
await db.refresh(payment)
return payment
+159
View File
@@ -0,0 +1,159 @@
"""CRUD операции для платежей Jupiter (FPGate P2P v2.1)."""
from datetime import UTC, datetime
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import JupiterPayment
logger = structlog.get_logger(__name__)
async def create_jupiter_payment(
db: AsyncSession,
*,
user_id: int | None,
order_id: str,
amount_kopeks: int,
currency: str = 'RUB',
description: str | None = None,
payment_url: str | None = None,
payment_method: str | None = None,
jupiter_transaction_id: str | None = None,
expires_at: datetime | None = None,
metadata_json: dict | None = None,
) -> JupiterPayment:
"""Создаёт запись о платеже Jupiter."""
payment = JupiterPayment(
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
payment_method=payment_method,
jupiter_transaction_id=jupiter_transaction_id,
expires_at=expires_at,
metadata_json=metadata_json,
status='pending',
is_paid=False,
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info('Создан платеж Jupiter', order_id=order_id, user_id=user_id)
return payment
async def get_jupiter_payment_by_order_id(db: AsyncSession, order_id: str) -> JupiterPayment | None:
"""Получает платеж по order_id (internal)."""
result = await db.execute(select(JupiterPayment).where(JupiterPayment.order_id == order_id))
return result.scalar_one_or_none()
async def get_jupiter_payment_by_invoice_id(
db: AsyncSession, jupiter_transaction_id: str
) -> JupiterPayment | None:
"""Получает платёж по transaction_id, выданному Jupiter."""
result = await db.execute(
select(JupiterPayment).where(JupiterPayment.jupiter_transaction_id == jupiter_transaction_id)
)
return result.scalar_one_or_none()
async def get_jupiter_payment_by_id(db: AsyncSession, payment_id: int) -> JupiterPayment | None:
"""Получает платеж по локальному ID."""
result = await db.execute(select(JupiterPayment).where(JupiterPayment.id == payment_id))
return result.scalar_one_or_none()
async def get_jupiter_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> JupiterPayment | None:
"""Получает платёж с блокировкой FOR UPDATE."""
result = await db.execute(
select(JupiterPayment)
.where(JupiterPayment.id == payment_id)
.with_for_update()
.execution_options(populate_existing=True)
)
return result.scalar_one_or_none()
async def update_jupiter_payment_status(
db: AsyncSession,
payment: JupiterPayment,
*,
status: str,
is_paid: bool | None = None,
jupiter_transaction_id: str | None = None,
payment_method: str | None = None,
callback_payload: dict | None = None,
transaction_id: int | None = None,
) -> JupiterPayment:
"""Обновляет статус платежа."""
payment.status = status
payment.updated_at = datetime.now(UTC)
if is_paid is not None:
payment.is_paid = is_paid
if is_paid:
payment.paid_at = datetime.now(UTC)
if jupiter_transaction_id is not None:
payment.jupiter_transaction_id = jupiter_transaction_id
if payment_method is not None:
payment.payment_method = payment_method
if callback_payload is not None:
payment.callback_payload = callback_payload
if transaction_id is not None:
payment.transaction_id = transaction_id
await db.commit()
await db.refresh(payment)
logger.info(
'Обновлён статус платежа Jupiter',
order_id=payment.order_id,
status=status,
is_paid=payment.is_paid,
)
return payment
async def get_pending_jupiter_payments(db: AsyncSession, user_id: int) -> list[JupiterPayment]:
"""Возвращает незавершённые платежи пользователя."""
result = await db.execute(
select(JupiterPayment).where(
JupiterPayment.user_id == user_id,
JupiterPayment.status == 'pending',
JupiterPayment.is_paid == False,
)
)
return list(result.scalars().all())
async def get_expired_pending_jupiter_payments(db: AsyncSession) -> list[JupiterPayment]:
"""Возвращает просроченные платежи в статусе pending."""
now = datetime.now(UTC)
result = await db.execute(
select(JupiterPayment).where(
JupiterPayment.status == 'pending',
JupiterPayment.is_paid == False,
JupiterPayment.expires_at < now,
)
)
return list(result.scalars().all())
async def link_jupiter_payment_to_transaction(
db: AsyncSession,
*,
payment: JupiterPayment,
transaction_id: int,
) -> JupiterPayment:
"""Связывает платёж с транзакцией."""
payment.transaction_id = transaction_id
payment.updated_at = datetime.now(UTC)
await db.flush()
await db.refresh(payment)
return payment
+126
View File
@@ -169,6 +169,8 @@ class PaymentMethod(Enum):
AURAPAY = 'aurapay'
ETOPLATEZHI = 'etoplatezhi'
ANTILOPAY = 'antilopay'
JUPITER = 'jupiter'
DONUT = 'donut'
MANUAL = 'manual'
BALANCE = 'balance'
@@ -1292,6 +1294,130 @@ class AntilopayPayment(Base):
return f'<AntilopayPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
class JupiterPayment(Base):
"""Платежи через Jupiter (FPGate P2P v2.1, app.juppiter.tech)."""
__tablename__ = 'jupiter_payments'
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True)
# Идентификаторы
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш internal ID
jupiter_transaction_id = Column(String(128), unique=True, nullable=True, index=True) # transaction_id от Jupiter
# Суммы
amount_kopeks = Column(Integer, nullable=False)
currency = Column(String(10), nullable=False, default='RUB')
description = Column(Text, nullable=True)
# Статусы
status = Column(String(32), nullable=False, default='pending')
is_paid = Column(Boolean, default=False)
# Данные платежа
payment_url = Column(Text, nullable=True) # qrcode_url из details (если есть)
payment_method = Column(String(32), nullable=True) # 'sbp' и т.д.
# Метаданные
metadata_json = Column(JSON, nullable=True)
callback_payload = Column(JSON, nullable=True)
# Временные метки
paid_at = Column(AwareDateTime(), nullable=True)
expires_at = Column(AwareDateTime(), nullable=True)
created_at = Column(AwareDateTime(), default=func.now())
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
# Связь с транзакцией
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
# Relationships
user = relationship('User', backref='jupiter_payments')
transaction = relationship('Transaction', backref='jupiter_payment')
@property
def amount_rubles(self) -> float:
return self.amount_kopeks / 100
@property
def is_pending(self) -> bool:
return self.status == 'pending'
@property
def is_success(self) -> bool:
return self.status == 'success' and self.is_paid
@property
def is_failed(self) -> bool:
return self.status in ['failed', 'expired', 'cancelled', 'amount_mismatch', 'declined', 'error']
def __repr__(self) -> str: # pragma: no cover - debug helper
return f'<JupiterPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
class DonutPayment(Base):
"""Платежи через Donut P2P (gw.donut.business)."""
__tablename__ = 'donut_payments'
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True)
# Идентификаторы
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш internal ID
donut_transaction_id = Column(String(128), unique=True, nullable=True, index=True) # transaction_id от Donut
# Суммы
amount_kopeks = Column(Integer, nullable=False)
currency = Column(String(10), nullable=False, default='RUB')
description = Column(Text, nullable=True)
# Статусы
status = Column(String(32), nullable=False, default='pending')
is_paid = Column(Boolean, default=False)
# Данные платежа
payment_url = Column(Text, nullable=True) # redirect_url или qrcode_url
payment_method = Column(String(32), nullable=True) # 'card', 'sbp', 'sbp_qr'
# Метаданные
metadata_json = Column(JSON, nullable=True)
callback_payload = Column(JSON, nullable=True)
# Временные метки
paid_at = Column(AwareDateTime(), nullable=True)
expires_at = Column(AwareDateTime(), nullable=True)
created_at = Column(AwareDateTime(), default=func.now())
updated_at = Column(AwareDateTime(), default=func.now(), onupdate=func.now())
# Связь с транзакцией
transaction_id = Column(Integer, ForeignKey('transactions.id'), nullable=True)
# Relationships
user = relationship('User', backref='donut_payments')
transaction = relationship('Transaction', backref='donut_payment')
@property
def amount_rubles(self) -> float:
return self.amount_kopeks / 100
@property
def is_pending(self) -> bool:
return self.status in ('pending', 'created', 'processing')
@property
def is_success(self) -> bool:
return self.status == 'success' and self.is_paid
@property
def is_failed(self) -> bool:
return self.status in ['failed', 'expired', 'cancelled', 'amount_mismatch', 'error']
def __repr__(self) -> str: # pragma: no cover - debug helper
return f'<DonutPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
class PromoGroup(Base):
__tablename__ = 'promo_groups'
+305
View File
@@ -0,0 +1,305 @@
"""Handler for Donut balance top-up (Donut P2P)."""
import html
import structlog
from aiogram import types
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.states import BalanceStates
from app.utils.decorators import error_handler
logger = structlog.get_logger(__name__)
DONUT_PAYMENT_METHODS = {'donut', 'donut_card', 'donut_sbp', 'donut_sbp_qr'}
DONUT_SERVICE_MAP: dict[str, str | None] = {
'donut': None,
'donut_card': 'card',
'donut_sbp': 'sbp',
'donut_sbp_qr': 'sbp_qr',
}
def _extract_service_type(payment_method: str) -> str | None:
return DONUT_SERVICE_MAP.get(payment_method)
def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None:
"""Проверяет ограничение на пополнение."""
if not getattr(db_user, 'restriction_topup', False):
return None
keyboard = []
support_url = settings.get_support_contact_url()
if support_url:
keyboard.append([InlineKeyboardButton(text='\U0001f198 Обжаловать', url=support_url)])
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def _get_display_name(payment_method: str) -> str:
if payment_method == 'donut_card':
return settings.get_donut_card_display_name()
if payment_method == 'donut_sbp':
return settings.get_donut_sbp_display_name()
if payment_method == 'donut_sbp_qr':
return settings.get_donut_sbp_qr_display_name()
return settings.get_donut_display_name()
async def _create_donut_payment_and_respond(
message_or_callback,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
edit_message: bool = False,
payment_method_type: str | None = None,
display_name: str | None = None,
):
"""Создаёт платёж Donut и отправляет ссылку пользователю."""
texts = get_texts(db_user.language)
amount_rub = amount_kopeks / 100
payment_service = PaymentService()
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
service_name=settings.PAYMENT_SERVICE_NAME,
description='Пополнение баланса',
)
result = await payment_service.create_donut_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=description,
email=getattr(db_user, 'email', None),
language=db_user.language,
payment_method_type=payment_method_type,
)
if not result:
error_text = texts.t(
'PAYMENT_CREATE_ERROR',
'Не удалось создать платёж. Попробуйте позже.',
)
if edit_message:
await message_or_callback.edit_text(
error_text,
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
else:
await message_or_callback.answer(error_text, parse_mode='HTML')
return
payment_url = result.get('payment_url')
name = display_name or settings.get_donut_display_name()
pay_button_text = texts.t('PAY_BUTTON', '\U0001f4b3 Оплатить {amount}').format(
amount=f'{amount_rub:.0f}',
)
keyboard_buttons: list[list[InlineKeyboardButton]] = []
if payment_url:
keyboard_buttons.append([InlineKeyboardButton(text=pay_button_text, url=payment_url)])
keyboard_buttons.append(
[
InlineKeyboardButton(
text=texts.t('BACK_BUTTON', '◀️ Назад'),
callback_data='menu_balance',
)
]
)
keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_buttons)
if payment_url:
response_text = texts.t(
'DONUT_PAYMENT_CREATED',
'\U0001f4b3 <b>Оплата через {name}</b>\n\n'
'Сумма: <b>{amount}₽</b>\n\n'
'Нажмите кнопку ниже для перехода к оплате.\n'
'После подтверждения платежа баланс будет пополнен автоматически.',
).format(name=name, amount=f'{amount_rub:.2f}')
else:
response_text = texts.t(
'DONUT_PAYMENT_PROCESSING',
'\U0001f4b3 <b>Платёж создан через {name}</b>\n\n'
'Сумма: <b>{amount}₽</b>\n\n'
'Платёж в обработке. Реквизиты будут отправлены отдельным сообщением.',
).format(name=name, amount=f'{amount_rub:.2f}')
if edit_message:
await message_or_callback.edit_text(response_text, reply_markup=keyboard, parse_mode='HTML')
else:
await message_or_callback.answer(response_text, reply_markup=keyboard, parse_mode='HTML')
logger.info('Donut payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
@error_handler
async def process_donut_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
):
"""Обрабатывает сумму, введённую пользователем для Donut."""
texts = get_texts(db_user.language)
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
await message.answer(
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=restriction_kb,
)
await state.clear()
return
min_amount = settings.DONUT_MIN_AMOUNT_KOPEKS
max_amount = settings.DONUT_MAX_AMOUNT_KOPEKS
if amount_kopeks < min_amount:
await message.answer(
texts.t(
'PAYMENT_AMOUNT_TOO_LOW',
'Минимальная сумма пополнения: {min_amount}',
).format(min_amount=min_amount // 100),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
if amount_kopeks > max_amount:
await message.answer(
texts.t(
'PAYMENT_AMOUNT_TOO_HIGH',
'Максимальная сумма пополнения: {max_amount}',
).format(max_amount=max_amount // 100),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
data = await state.get_data()
payment_method = data.get('payment_method', 'donut')
payment_method_type = _extract_service_type(payment_method)
display_name = _get_display_name(payment_method)
await state.clear()
await _create_donut_payment_and_respond(
message_or_callback=message,
db_user=db_user,
db=db,
amount_kopeks=amount_kopeks,
edit_message=False,
payment_method_type=payment_method_type,
display_name=display_name,
)
async def _start_donut_topup_impl(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
payment_method: str,
):
"""Стартует FSM ввода суммы для Donut."""
texts = get_texts(db_user.language)
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
await callback.message.edit_text(
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=restriction_kb,
)
return
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method=payment_method)
min_amount = settings.DONUT_MIN_AMOUNT_KOPEKS // 100
max_amount = settings.DONUT_MAX_AMOUNT_KOPEKS // 100
display_name = _get_display_name(payment_method)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t('BACK_BUTTON', '◀️ Назад'),
callback_data='menu_balance',
)
]
]
)
await callback.message.edit_text(
texts.t(
'DONUT_ENTER_AMOUNT',
'\U0001f4b3 <b>Пополнение через {name}</b>\n\n'
'Введите сумму пополнения в рублях.\n\n'
'Минимум: {min_amount}\n'
'Максимум: {max_amount}',
).format(
name=display_name,
min_amount=min_amount,
max_amount=f'{max_amount:,}'.replace(',', ' '),
),
parse_mode='HTML',
reply_markup=keyboard,
)
@error_handler
async def start_donut_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _start_donut_topup_impl(callback, db_user, state, 'donut')
@error_handler
async def start_donut_card_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _start_donut_topup_impl(callback, db_user, state, 'donut_card')
@error_handler
async def start_donut_sbp_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _start_donut_topup_impl(callback, db_user, state, 'donut_sbp')
@error_handler
async def start_donut_sbp_qr_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _start_donut_topup_impl(callback, db_user, state, 'donut_sbp_qr')
+273
View File
@@ -0,0 +1,273 @@
"""Handler for Jupiter balance top-up (FPGate P2P v2.1)."""
import html
import structlog
from aiogram import types
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.states import BalanceStates
from app.utils.decorators import error_handler
logger = structlog.get_logger(__name__)
JUPITER_PAYMENT_METHODS = {'jupiter', 'jupiter_sbp'}
JUPITER_SERVICE_MAP: dict[str, str | None] = {
'jupiter': None,
'jupiter_sbp': 'sbp',
}
def _extract_service_type(payment_method: str) -> str | None:
return JUPITER_SERVICE_MAP.get(payment_method)
def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None:
"""Проверяет ограничение на пополнение."""
if not getattr(db_user, 'restriction_topup', False):
return None
keyboard = []
support_url = settings.get_support_contact_url()
if support_url:
keyboard.append([InlineKeyboardButton(text='\U0001f198 Обжаловать', url=support_url)])
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
async def _create_jupiter_payment_and_respond(
message_or_callback,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
edit_message: bool = False,
payment_method_type: str | None = None,
):
"""Создаёт платёж Jupiter и отправляет ссылку/QR пользователю."""
texts = get_texts(db_user.language)
amount_rub = amount_kopeks / 100
payment_service = PaymentService()
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
service_name=settings.PAYMENT_SERVICE_NAME,
description='Пополнение баланса',
)
result = await payment_service.create_jupiter_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=description,
email=getattr(db_user, 'email', None),
language=db_user.language,
payment_method_type=payment_method_type,
)
if not result:
error_text = texts.t(
'PAYMENT_CREATE_ERROR',
'Не удалось создать платёж. Попробуйте позже.',
)
if edit_message:
await message_or_callback.edit_text(
error_text,
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
else:
await message_or_callback.answer(error_text, parse_mode='HTML')
return
payment_url = result.get('payment_url')
display_name = settings.get_jupiter_display_name()
pay_button_text = texts.t('PAY_BUTTON', '\U0001f4b3 Оплатить {amount}').format(
amount=f'{amount_rub:.0f}',
)
keyboard_buttons: list[list[InlineKeyboardButton]] = []
if payment_url:
keyboard_buttons.append([InlineKeyboardButton(text=pay_button_text, url=payment_url)])
keyboard_buttons.append(
[
InlineKeyboardButton(
text=texts.t('BACK_BUTTON', '◀️ Назад'),
callback_data='menu_balance',
)
]
)
keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_buttons)
if payment_url:
response_text = texts.t(
'JUPITER_PAYMENT_CREATED',
'\U0001f4b3 <b>Оплата через {name}</b>\n\n'
'Сумма: <b>{amount}₽</b>\n\n'
'Нажмите кнопку ниже, чтобы открыть QR-код СБП и оплатить.\n'
'Баланс будет пополнен автоматически после подтверждения платежа.',
).format(name=display_name, amount=f'{amount_rub:.2f}')
else:
response_text = texts.t(
'JUPITER_PAYMENT_PROCESSING',
'\U0001f4b3 <b>Платёж создан через {name}</b>\n\n'
'Сумма: <b>{amount}₽</b>\n\n'
'Платёж в обработке. Реквизиты будут отправлены отдельным сообщением.',
).format(name=display_name, amount=f'{amount_rub:.2f}')
if edit_message:
await message_or_callback.edit_text(response_text, reply_markup=keyboard, parse_mode='HTML')
else:
await message_or_callback.answer(response_text, reply_markup=keyboard, parse_mode='HTML')
logger.info('Jupiter payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
@error_handler
async def process_jupiter_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
):
"""Обрабатывает сумму, введённую пользователем для Jupiter."""
texts = get_texts(db_user.language)
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
await message.answer(
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=restriction_kb,
)
await state.clear()
return
min_amount = settings.JUPITER_MIN_AMOUNT_KOPEKS
max_amount = settings.JUPITER_MAX_AMOUNT_KOPEKS
if amount_kopeks < min_amount:
await message.answer(
texts.t(
'PAYMENT_AMOUNT_TOO_LOW',
'Минимальная сумма пополнения: {min_amount}',
).format(min_amount=min_amount // 100),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
if amount_kopeks > max_amount:
await message.answer(
texts.t(
'PAYMENT_AMOUNT_TOO_HIGH',
'Максимальная сумма пополнения: {max_amount}',
).format(max_amount=max_amount // 100),
reply_markup=get_back_keyboard(db_user.language),
parse_mode='HTML',
)
return
data = await state.get_data()
payment_method = data.get('payment_method', 'jupiter')
payment_method_type = _extract_service_type(payment_method)
await state.clear()
await _create_jupiter_payment_and_respond(
message_or_callback=message,
db_user=db_user,
db=db,
amount_kopeks=amount_kopeks,
edit_message=False,
payment_method_type=payment_method_type,
)
async def _start_jupiter_topup_impl(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
payment_method: str,
):
"""Стартует FSM ввода суммы для Jupiter."""
texts = get_texts(db_user.language)
restriction_kb = _check_topup_restriction(db_user, texts)
if restriction_kb:
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
await callback.message.edit_text(
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
parse_mode='HTML',
reply_markup=restriction_kb,
)
return
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method=payment_method)
min_amount = settings.JUPITER_MIN_AMOUNT_KOPEKS // 100
max_amount = settings.JUPITER_MAX_AMOUNT_KOPEKS // 100
if payment_method == 'jupiter_sbp':
display_name = settings.get_jupiter_sbp_display_name()
else:
display_name = settings.get_jupiter_display_name()
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t('BACK_BUTTON', '◀️ Назад'),
callback_data='menu_balance',
)
]
]
)
await callback.message.edit_text(
texts.t(
'JUPITER_ENTER_AMOUNT',
'\U0001f4b3 <b>Пополнение через {name}</b>\n\n'
'Введите сумму пополнения в рублях.\n\n'
'Минимум: {min_amount}\n'
'Максимум: {max_amount}',
).format(
name=display_name,
min_amount=min_amount,
max_amount=f'{max_amount:,}'.replace(',', ' '),
),
parse_mode='HTML',
reply_markup=keyboard,
)
@error_handler
async def start_jupiter_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _start_jupiter_topup_impl(callback, db_user, state, 'jupiter')
@error_handler
async def start_jupiter_sbp_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _start_jupiter_topup_impl(callback, db_user, state, 'jupiter_sbp')
+31
View File
@@ -191,6 +191,20 @@ async def route_payment_by_method(
await process_antilopay_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method in ('jupiter', 'jupiter_sbp'):
from .jupiter import process_jupiter_payment_amount
async with AsyncSessionLocal() as db:
await process_jupiter_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method in ('donut', 'donut_card', 'donut_sbp', 'donut_sbp_qr'):
from .donut import process_donut_payment_amount
async with AsyncSessionLocal() as db:
await process_donut_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method == 'riopay':
from .riopay import process_riopay_payment_amount
@@ -806,6 +820,23 @@ def register_balance_handlers(dp: Dispatcher):
dp.callback_query.register(start_antilopay_card_topup, F.data == 'topup_antilopay_card')
dp.callback_query.register(start_antilopay_sberpay_topup, F.data == 'topup_antilopay_sberpay')
from .jupiter import start_jupiter_sbp_topup, start_jupiter_topup
dp.callback_query.register(start_jupiter_topup, F.data == 'topup_jupiter')
dp.callback_query.register(start_jupiter_sbp_topup, F.data == 'topup_jupiter_sbp')
from .donut import (
start_donut_card_topup,
start_donut_sbp_qr_topup,
start_donut_sbp_topup,
start_donut_topup,
)
dp.callback_query.register(start_donut_topup, F.data == 'topup_donut')
dp.callback_query.register(start_donut_card_topup, F.data == 'topup_donut_card')
dp.callback_query.register(start_donut_sbp_topup, F.data == 'topup_donut_sbp')
dp.callback_query.register(start_donut_sbp_qr_topup, F.data == 'topup_donut_sbp_qr')
from .mulenpay import check_mulenpay_payment_status
dp.callback_query.register(check_mulenpay_payment_status, F.data.startswith('check_mulenpay_'))
+77
View File
@@ -1983,6 +1983,83 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
)
has_direct_payment_methods = True
if settings.is_jupiter_sbp_enabled():
jupiter_sbp_name = settings.get_jupiter_sbp_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_JUPITER_SBP', f'📱 {jupiter_sbp_name}'),
callback_data=_build_callback('jupiter_sbp'),
)
]
)
has_direct_payment_methods = True
if settings.is_jupiter_enabled() and not settings.is_jupiter_sbp_enabled():
jupiter_name = settings.get_jupiter_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_JUPITER', f'🪐 {jupiter_name}'),
callback_data=_build_callback('jupiter'),
)
]
)
has_direct_payment_methods = True
if settings.is_donut_card_enabled():
donut_card_name = settings.get_donut_card_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_DONUT_CARD', f'💳 {donut_card_name}'),
callback_data=_build_callback('donut_card'),
)
]
)
has_direct_payment_methods = True
if settings.is_donut_sbp_enabled():
donut_sbp_name = settings.get_donut_sbp_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_DONUT_SBP', f'📱 {donut_sbp_name}'),
callback_data=_build_callback('donut_sbp'),
)
]
)
has_direct_payment_methods = True
if settings.is_donut_sbp_qr_enabled():
donut_qr_name = settings.get_donut_sbp_qr_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_DONUT_SBP_QR', f'🏦 {donut_qr_name}'),
callback_data=_build_callback('donut_sbp_qr'),
)
]
)
has_direct_payment_methods = True
if (
settings.is_donut_enabled()
and not settings.is_donut_card_enabled()
and not settings.is_donut_sbp_enabled()
and not settings.is_donut_sbp_qr_enabled()
):
donut_name = settings.get_donut_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_DONUT', f'🍩 {donut_name}'),
callback_data=_build_callback('donut'),
)
]
)
has_direct_payment_methods = True
if settings.is_support_topup_enabled():
keyboard.append(
[
+266
View File
@@ -0,0 +1,266 @@
"""Сервис для работы с 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()
+268
View File
@@ -0,0 +1,268 @@
"""Сервис для работы с API Jupiter (FPGate P2P v2.1, app.juppiter.tech)."""
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 JupiterAPIError(Exception):
"""Ошибка API Jupiter."""
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'Jupiter API error ({status_code}): {message}')
class JupiterService:
"""Клиент для FPGate P2P v2.1 (Jupiter / app.juppiter.tech)."""
def __init__(self) -> None:
self._session: aiohttp.ClientSession | None = None
@property
def base_url(self) -> str:
return (settings.JUPITER_BASE_URL or 'https://app.juppiter.tech').rstrip('/')
@property
def token(self) -> str:
return settings.JUPITER_TOKEN or ''
@property
def secret(self) -> str:
return settings.JUPITER_SECRET or ''
@property
def method_id(self) -> str | None:
value = (settings.JUPITER_METHOD_ID or '').strip()
return value or None
@property
def method_description(self) -> str:
return (settings.JUPITER_METHOD_DESCRIPTION or 'SBP').strip() or 'SBP'
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:
"""Собирает каноническую строку для подписи: имя=значение... в порядке полей.
По спецификации FPGate P2P v2.1: «Если поле подписываемое, но не обязательное,
то оно входит в подпись, если оно присутствует в запросе и имеет непустое значение».
"""
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"}')
continue
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' с точкой-разделителем (требование P2P v2.1)."""
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('Jupiter API connection error', url=url, error=error)
raise
async def create_payment(
self,
*,
amount_rubles: float,
order_id: str,
customer_id: str,
customer_email: str | None = None,
customer_phone: str | None = None,
customer_name: str | None = None,
callback_url: str | None = None,
receipt: str | None = None,
description: str | None = None,
) -> dict[str, Any]:
"""Создаёт платёж (PayIn) согласно FPGate P2P v2.1.
POST /p2p_payin_v2.1
"""
payload: dict[str, Any] = {
'token': self.token,
'order_id': order_id,
'amount': {
'value': self._format_amount(amount_rubles),
'currency': (settings.JUPITER_CURRENCY or 'RUB').upper(),
},
'customer': {
'id': str(customer_id),
'email': customer_email or settings.JUPITER_FALLBACK_EMAIL or 'user@vpn.bot',
'phone': customer_phone or settings.JUPITER_FALLBACK_PHONE or '0000000000',
'name': customer_name or settings.JUPITER_FALLBACK_NAME or 'User',
},
'redirect': 'false',
'description': (description or self.method_description)[:255],
}
if self.method_id:
payload['method_id'] = self.method_id
if callback_url:
payload['callback_url'] = callback_url
if receipt:
payload['receipt'] = receipt[:255]
payload['signature'] = self._sign_payin(payload)
logger.info('Jupiter API create_payment', order_id=order_id, amount_rubles=amount_rubles)
data = await self._post('/p2p_payin_v2.1', payload)
status = (data.get('status') or {}) if isinstance(data, dict) else {}
status_type = status.get('type')
if status_type in ('processing', 'success'):
logger.info(
'Jupiter API payment created',
order_id=order_id,
transaction_id=data.get('transaction_id'),
status_type=status_type,
)
return data
error_code = status.get('error_code') or '0'
error_msg = status.get('error_description') or 'Unknown error'
logger.error(
'Jupiter create_payment error',
error_code=error_code,
error_msg=error_msg,
response_data=data,
)
raise JupiterAPIError(200, error_msg, error_code)
async def check_payment(self, *, transaction_id: str) -> dict[str, Any]:
"""Получает статус платежа.
POST /p2p_status_v2.1
"""
payload: dict[str, Any] = {
'token': self.token,
'transaction_id': str(transaction_id),
}
payload['signature'] = self._sign_status(payload)
logger.info('Jupiter check_payment', transaction_id=transaction_id)
data = await self._post('/p2p_status_v2.1', payload)
return data
async def get_balance(self) -> dict[str, Any]:
"""Получает баланс продавца.
POST /p2p_balance_v2.1
"""
payload: dict[str, Any] = {'token': self.token}
payload['signature'] = self._sign_balance(payload)
data = await self._post('/p2p_balance_v2.1', payload)
return data
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('Jupiter callback: отсутствует signature')
return False
amount = payload.get('amount') or {}
status = 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.get('type')),
]
expected = self._hmac_hex(self._build_signature_string(parts))
if not hmac.compare_digest(expected.lower(), received.lower()):
logger.warning(
'Jupiter callback: invalid signature',
expected_prefix=expected[:8],
received_prefix=received[:8],
)
return False
return True
except Exception as error:
logger.error('Jupiter callback verify error', error=error)
return False
# Singleton instance
jupiter_service = JupiterService()
+506
View File
@@ -0,0 +1,506 @@
"""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
+498
View File
@@ -0,0 +1,498 @@
"""Mixin для интеграции с Jupiter (FPGate P2P v2.1, app.juppiter.tech)."""
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.jupiter_service import jupiter_service
from app.utils.payment_logger import payment_logger as logger
from app.utils.user_utils import format_referrer_info
# Маппинг статусов Jupiter -> internal
JUPITER_STATUS_MAP: dict[str, tuple[str, bool]] = {
'success': ('success', True),
'processing': ('pending', False),
'cancelled': ('cancelled', False),
'declined': ('declined', False),
'error': ('error', False),
}
class JupiterPaymentMixin:
"""Mixin для работы с платежами Jupiter."""
async def create_jupiter_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:
"""Создаёт платёж Jupiter.
Параметр ``return_url`` принимается для совместимости сигнатуры с другими провайдерами
(cabinet/routes/balance.py одинаково вызывает все ``create_*_payment``), но фактически
не используется: спецификация Jupiter v2.1 имеет поле ``redirect`` зарезервированное
для будущего использования и не поддерживает return-URL семантику. Пользователь
видит QR-код СБП и подтверждает оплату в банковском приложении.
"""
if not settings.is_jupiter_enabled():
logger.error('Jupiter не настроен')
return None
if amount_kopeks < settings.JUPITER_MIN_AMOUNT_KOPEKS:
logger.warning(
'Jupiter: сумма меньше минимальной',
amount_kopeks=amount_kopeks,
JUPITER_MIN_AMOUNT_KOPEKS=settings.JUPITER_MIN_AMOUNT_KOPEKS,
)
return None
if amount_kopeks > settings.JUPITER_MAX_AMOUNT_KOPEKS:
logger.warning(
'Jupiter: сумма больше максимальной',
amount_kopeks=amount_kopeks,
JUPITER_MAX_AMOUNT_KOPEKS=settings.JUPITER_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'jup{tg_id}_{uuid.uuid4().hex[:6]}'
amount_rubles = amount_kopeks / 100
currency = settings.JUPITER_CURRENCY
metadata = {
'user_id': user_id,
'amount_kopeks': amount_kopeks,
'description': description,
'language': language,
'type': 'balance_topup',
'payment_method_type': payment_method_type,
}
try:
callback_url = self._build_jupiter_callback_url()
customer_id = str(tg_id) if tg_id != 'guest' else f'guest-{order_id[-6:]}'
customer_name = (
getattr(user, 'first_name', None)
or getattr(user, 'username', None)
or settings.JUPITER_FALLBACK_NAME
)
api_result = await jupiter_service.create_payment(
amount_rubles=amount_rubles,
order_id=order_id,
customer_id=customer_id,
customer_email=email,
customer_name=customer_name,
callback_url=callback_url,
description=description[:255] if description else None,
)
transaction_id = api_result.get('transaction_id')
details = api_result.get('details') or {}
payment_url = details.get('qrcode_url') or details.get('url')
logger.info(
'Jupiter: получен ответ API',
order_id=order_id,
transaction_id=transaction_id,
payment_url=payment_url,
)
lifetime = settings.JUPITER_PAYMENT_LIFETIME_MINUTES
expires_at = datetime.now(UTC) + timedelta(minutes=lifetime)
jupiter_crud = import_module('app.database.crud.jupiter')
local_payment = await jupiter_crud.create_jupiter_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=payment_method_type,
jupiter_transaction_id=str(transaction_id) if transaction_id else None,
expires_at=expires_at,
metadata_json=metadata,
)
logger.info(
'Jupiter: создан платеж',
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('Jupiter: ошибка создания платежа', error=e)
return None
@staticmethod
def _build_jupiter_callback_url() -> str | None:
"""Собирает абсолютный callback URL для Jupiter."""
webhook_path = settings.JUPITER_WEBHOOK_PATH or '/jupiter-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_jupiter_callback(
self,
db: AsyncSession,
payload: dict[str, Any],
) -> bool:
"""Обрабатывает callback от Jupiter (подпись уже проверена в webserver)."""
try:
our_order_id = payload.get('order_id')
jupiter_transaction_id = payload.get('transaction_id')
status_obj = payload.get('status') or {}
jupiter_status = (status_obj.get('type') or '').strip().lower()
if not our_order_id or not jupiter_status:
logger.warning('Jupiter callback: отсутствуют обязательные поля', payload=payload)
return False
jupiter_crud = import_module('app.database.crud.jupiter')
payment = await jupiter_crud.get_jupiter_payment_by_order_id(db, our_order_id)
if not payment:
logger.warning('Jupiter callback: платеж не найден', order_id=our_order_id)
return False
locked = await jupiter_crud.get_jupiter_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('Jupiter: не удалось заблокировать платёж', payment_id=payment.id)
return False
payment = locked
if payment.is_paid:
logger.info('Jupiter callback: платеж уже обработан', order_id=payment.order_id)
return True
# Терминальные неуспешные статусы стики — провайдер не должен иметь возможность
# «починить» отклонённый/несовпавший платёж повторным callback'ом.
if payment.status in {'amount_mismatch', 'cancelled', 'declined', 'error', 'expired'}:
logger.warning(
'Jupiter callback: платёж в терминальном неуспешном статусе, игнорируется',
order_id=payment.order_id,
current_status=payment.status,
incoming_status=jupiter_status,
)
return True
internal_status, is_paid = JUPITER_STATUS_MAP.get(jupiter_status, ('pending', False))
callback_payload = {
'jupiter_transaction_id': jupiter_transaction_id,
'status_type': jupiter_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(
'Jupiter amount mismatch',
expected_kopeks=payment.amount_kopeks,
received_kopeks=received_kopeks,
order_id=payment.order_id,
)
await jupiter_crud.update_jupiter_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.jupiter_transaction_id = (
str(jupiter_transaction_id) if jupiter_transaction_id else payment.jupiter_transaction_id
)
payment.callback_payload = callback_payload
payment.updated_at = datetime.now(UTC)
await db.flush()
return await self._finalize_jupiter_payment(db, payment, trigger='webhook')
payment = await jupiter_crud.update_jupiter_payment_status(
db=db,
payment=payment,
status=internal_status,
is_paid=False,
callback_payload=callback_payload,
)
return True
except Exception as e:
logger.exception('Jupiter callback: ошибка обработки', error=e)
return False
async def _finalize_jupiter_payment(
self,
db: AsyncSession,
payment: Any,
*,
trigger: str,
) -> bool:
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления.
FOR UPDATE lock уже взят вызывающим.
"""
payment_module = import_module('app.services.payment_service')
jupiter_crud = import_module('app.database.crud.jupiter')
if payment.transaction_id:
logger.info(
'Jupiter платеж уже связан с транзакцией',
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='jupiter',
)
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('Пользователь не найден для Jupiter', 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.JUPITER,
)
display_name = settings.get_jupiter_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.JUPITER,
external_id=transaction_external_id,
is_completed=True,
created_at=getattr(payment, 'created_at', None),
commit=False,
)
created_transaction = True
await jupiter_crud.link_jupiter_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('Jupiter платеж уже зачислил баланс ранее', 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.JUPITER,
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('Ошибка обработки реферального пополнения Jupiter', 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('Ошибка отправки админ уведомления Jupiter', 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('Ошибка отправки уведомления пользователю Jupiter', 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(
'Обработан Jupiter платеж',
order_id=payment.order_id,
user_id=payment.user_id,
trigger=trigger,
)
return True
async def check_jupiter_payment_status(
self,
db: AsyncSession,
transaction_id: str,
) -> dict[str, Any] | None:
"""Запрос статуса платежа через API Jupiter."""
try:
return await jupiter_service.check_payment(transaction_id=transaction_id)
except Exception as e:
logger.error('Jupiter: ошибка проверки статуса', transaction_id=transaction_id, error=e)
return None
@@ -210,6 +210,26 @@ def _get_method_defaults() -> dict:
{'id': 'sberpay', 'name': 'SberPay'},
],
},
'jupiter': {
'default_display_name': settings.get_jupiter_display_name(),
'is_configured': settings.is_jupiter_enabled(),
'default_min': settings.JUPITER_MIN_AMOUNT_KOPEKS,
'default_max': settings.JUPITER_MAX_AMOUNT_KOPEKS,
'available_sub_options': [
{'id': 'sbp', 'name': 'СБП'},
],
},
'donut': {
'default_display_name': settings.get_donut_display_name(),
'is_configured': settings.is_donut_enabled(),
'default_min': settings.DONUT_MIN_AMOUNT_KOPEKS,
'default_max': settings.DONUT_MAX_AMOUNT_KOPEKS,
'available_sub_options': [
{'id': 'card', 'name': 'Карта'},
{'id': 'sbp', 'name': 'СБП'},
{'id': 'sbp_qr', 'name': 'СБП QR'},
],
},
}
@@ -258,6 +278,8 @@ DEFAULT_METHOD_ORDER = [
'aurapay',
'etoplatezhi',
'antilopay',
'jupiter',
'donut',
]
+118
View File
@@ -33,8 +33,10 @@ from app.services.payment import (
from app.services.payment.antilopay import AntilopayPaymentMixin
from app.services.payment.aurapay import AuraPayPaymentMixin
from app.services.payment.cloudpayments import CloudPaymentsPaymentMixin
from app.services.payment.donut import DonutPaymentMixin
from app.services.payment.etoplatezhi import EtoplatezhiPaymentMixin
from app.services.payment.freekassa import FreekassaPaymentMixin
from app.services.payment.jupiter import JupiterPaymentMixin
from app.services.payment.kassa_ai import KassaAiPaymentMixin
from app.services.payment.overpay import OverpayPaymentMixin
from app.services.payment.paypear import PayPearPaymentMixin
@@ -554,6 +556,76 @@ async def link_antilopay_payment_to_transaction(*args, **kwargs):
return await antilopay_crud.link_antilopay_payment_to_transaction(*args, **kwargs)
async def create_jupiter_payment(*args, **kwargs):
jupiter_crud = import_module('app.database.crud.jupiter')
return await jupiter_crud.create_jupiter_payment(*args, **kwargs)
async def get_jupiter_payment_by_order_id(*args, **kwargs):
jupiter_crud = import_module('app.database.crud.jupiter')
return await jupiter_crud.get_jupiter_payment_by_order_id(*args, **kwargs)
async def get_jupiter_payment_by_invoice_id(*args, **kwargs):
jupiter_crud = import_module('app.database.crud.jupiter')
return await jupiter_crud.get_jupiter_payment_by_invoice_id(*args, **kwargs)
async def get_jupiter_payment_by_id(*args, **kwargs):
jupiter_crud = import_module('app.database.crud.jupiter')
return await jupiter_crud.get_jupiter_payment_by_id(*args, **kwargs)
async def get_jupiter_payment_by_id_for_update(*args, **kwargs):
jupiter_crud = import_module('app.database.crud.jupiter')
return await jupiter_crud.get_jupiter_payment_by_id_for_update(*args, **kwargs)
async def update_jupiter_payment_status(*args, **kwargs):
jupiter_crud = import_module('app.database.crud.jupiter')
return await jupiter_crud.update_jupiter_payment_status(*args, **kwargs)
async def link_jupiter_payment_to_transaction(*args, **kwargs):
jupiter_crud = import_module('app.database.crud.jupiter')
return await jupiter_crud.link_jupiter_payment_to_transaction(*args, **kwargs)
async def create_donut_payment(*args, **kwargs):
donut_crud = import_module('app.database.crud.donut')
return await donut_crud.create_donut_payment(*args, **kwargs)
async def get_donut_payment_by_order_id(*args, **kwargs):
donut_crud = import_module('app.database.crud.donut')
return await donut_crud.get_donut_payment_by_order_id(*args, **kwargs)
async def get_donut_payment_by_invoice_id(*args, **kwargs):
donut_crud = import_module('app.database.crud.donut')
return await donut_crud.get_donut_payment_by_invoice_id(*args, **kwargs)
async def get_donut_payment_by_id(*args, **kwargs):
donut_crud = import_module('app.database.crud.donut')
return await donut_crud.get_donut_payment_by_id(*args, **kwargs)
async def get_donut_payment_by_id_for_update(*args, **kwargs):
donut_crud = import_module('app.database.crud.donut')
return await donut_crud.get_donut_payment_by_id_for_update(*args, **kwargs)
async def update_donut_payment_status(*args, **kwargs):
donut_crud = import_module('app.database.crud.donut')
return await donut_crud.update_donut_payment_status(*args, **kwargs)
async def link_donut_payment_to_transaction(*args, **kwargs):
donut_crud = import_module('app.database.crud.donut')
return await donut_crud.link_donut_payment_to_transaction(*args, **kwargs)
# Mapping from model_name to getter function name for providers
# where it differs from the standard get_{model_name}_payment_by_id pattern.
_GETTER_OVERRIDES: dict[str, str] = {
@@ -583,6 +655,8 @@ class PaymentService(
AuraPayPaymentMixin,
EtoplatezhiPaymentMixin,
AntilopayPaymentMixin,
JupiterPaymentMixin,
DonutPaymentMixin,
):
"""Основной интерфейс платежей, делегирующий работу специализированным mixin-ам."""
@@ -1134,6 +1208,50 @@ class PaymentService(
}
return None
# --- Jupiter ----------------------------------------------------------
if payment_method == 'jupiter':
if not settings.is_jupiter_enabled():
logger.warning('Jupiter is not enabled, cannot create guest payment')
return None
result = await self.create_jupiter_payment(
db=db,
user_id=None,
amount_kopeks=amount_kopeks,
description=description,
return_url=return_url,
)
if result:
await _patch_guest_metadata(result['local_payment_id'], 'jupiter')
return {
'payment_url': result.get('payment_url'),
'payment_id': result.get('order_id'),
'provider': 'jupiter',
}
return None
# --- Donut ------------------------------------------------------------
if payment_method == 'donut':
if not settings.is_donut_enabled():
logger.warning('Donut is not enabled, cannot create guest payment')
return None
result = await self.create_donut_payment(
db=db,
user_id=None,
amount_kopeks=amount_kopeks,
description=description,
return_url=return_url,
)
if result:
await _patch_guest_metadata(result['local_payment_id'], 'donut')
return {
'payment_url': result.get('payment_url'),
'payment_id': result.get('order_id'),
'provider': 'donut',
}
return None
# --- Telegram Stars ---------------------------------------------------
if payment_method == 'telegram_stars':
if not settings.TELEGRAM_STARS_ENABLED:
+12
View File
@@ -95,6 +95,10 @@ class BotConfigurationService:
'ROLLYPAY': '💳 RollyPay',
'OVERPAY': '💳 Overpay',
'AURAPAY': '💳 AuraPay',
'ANTILOPAY': '🦌 Antilopay',
'ETOPLATEZHI': '💳 Etoplatezhi',
'JUPITER': '🪐 Jupiter',
'DONUT': '🍩 Donut',
'YOOKASSA': '🟣 YooKassa',
'PLATEGA': '💳 {platega_name}',
'TRIBUTE': '🎁 Tribute',
@@ -159,6 +163,10 @@ class BotConfigurationService:
'ROLLYPAY': 'RollyPay: платёжный шлюз rollypay.io с СБП, картами и криптовалютой.',
'OVERPAY': 'Overpay: платёжный шлюз pay.overpay.io с mTLS и поддержкой карт и СБП.',
'AURAPAY': 'AuraPay: платёжный шлюз aurapay.tech с поддержкой карт и СБП.',
'ANTILOPAY': 'Antilopay: lk.antilopay.com, оплата картой, СБП и SberPay.',
'ETOPLATEZHI': 'Etoplatezhi: paymentpage.etoplatezhi.ru, оплата картой и через СБП.',
'JUPITER': 'Jupiter (FPGate P2P v2.1): app.juppiter.tech, эквайринг СБП с HMAC-SHA256.',
'DONUT': 'Donut P2P: gw.donut.business, P2P-оплата картой, СБП по телефону и QR.',
'PLATEGA': '{platega_name}: merchant ID, секрет, ссылки возврата и методы оплаты.',
'MULENPAY': 'Платежи {mulenpay_name} и параметры магазина.',
'PAL24': 'PAL24 / PayPalych подключения и лимиты.',
@@ -373,6 +381,10 @@ class BotConfigurationService:
'ROLLYPAY_': 'ROLLYPAY',
'OVERPAY_': 'OVERPAY',
'AURAPAY_': 'AURAPAY',
'ANTILOPAY_': 'ANTILOPAY',
'ETOPLATEZHI_': 'ETOPLATEZHI',
'JUPITER_': 'JUPITER',
'DONUT_': 'DONUT',
'PLATEGA_': 'PLATEGA',
'MULENPAY_': 'MULENPAY',
'PAL24_': 'PAL24',
+94
View File
@@ -1564,6 +1564,98 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
routes_registered = True
# Jupiter webhook (FPGate P2P v2.1)
if settings.is_jupiter_enabled():
@router.get(settings.JUPITER_WEBHOOK_PATH)
async def jupiter_health() -> JSONResponse:
return JSONResponse(
{
'status': 'ok',
'service': 'jupiter_webhook',
'enabled': settings.is_jupiter_enabled(),
}
)
@router.post(settings.JUPITER_WEBHOOK_PATH)
async def jupiter_webhook(request: Request) -> JSONResponse:
try:
raw_body = await request.body()
payload = json.loads(raw_body)
except Exception as parse_error:
logger.error('Jupiter webhook: failed to parse JSON', parse_error=parse_error)
return JSONResponse({'status': 'error'}, status_code=status.HTTP_400_BAD_REQUEST)
from app.services.jupiter_service import jupiter_service
if not jupiter_service.verify_callback_signature(payload):
logger.warning('Jupiter webhook: invalid signature')
return JSONResponse({'status': 'error'}, status_code=status.HTTP_400_BAD_REQUEST)
try:
success = await _process_payment_service_callback(
payment_service,
payload,
'process_jupiter_callback',
)
if not success:
logger.error(
'Jupiter webhook processing failed',
transaction_id=payload.get('transaction_id'),
)
except Exception as e:
logger.exception('Jupiter webhook processing error', error=e)
# FPGate ожидает HTTP 200 как подтверждение приёма callback
return JSONResponse({'status': 'ok'}, status_code=status.HTTP_200_OK)
routes_registered = True
# Donut webhook (Donut P2P)
if settings.is_donut_enabled():
@router.get(settings.DONUT_WEBHOOK_PATH)
async def donut_health() -> JSONResponse:
return JSONResponse(
{
'status': 'ok',
'service': 'donut_webhook',
'enabled': settings.is_donut_enabled(),
}
)
@router.post(settings.DONUT_WEBHOOK_PATH)
async def donut_webhook(request: Request) -> JSONResponse:
try:
raw_body = await request.body()
payload = json.loads(raw_body)
except Exception as parse_error:
logger.error('Donut webhook: failed to parse JSON', parse_error=parse_error)
return JSONResponse({'status': 'error'}, status_code=status.HTTP_400_BAD_REQUEST)
from app.services.donut_service import donut_service
if not donut_service.verify_callback_signature(payload):
logger.warning('Donut webhook: invalid signature')
return JSONResponse({'status': 'error'}, status_code=status.HTTP_400_BAD_REQUEST)
try:
success = await _process_payment_service_callback(
payment_service,
payload,
'process_donut_callback',
)
if not success:
logger.error(
'Donut webhook processing failed',
transaction_id=payload.get('transaction_id'),
)
except Exception as e:
logger.exception('Donut webhook processing error', error=e)
# Donut ожидает HTTP 200 как подтверждение приёма callback
return JSONResponse({'status': 'ok'}, status_code=status.HTTP_200_OK)
routes_registered = True
if routes_registered:
@router.get('/health/payment-webhooks')
@@ -1590,6 +1682,8 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
'aurapay_enabled': settings.is_aurapay_enabled(),
'etoplatezhi_enabled': settings.is_etoplatezhi_enabled(),
'antilopay_enabled': settings.is_antilopay_enabled(),
'jupiter_enabled': settings.is_jupiter_enabled(),
'donut_enabled': settings.is_donut_enabled(),
}
)
@@ -0,0 +1,45 @@
"""create jupiter_payments table
Revision ID: 0072
Revises: 0071
Create Date: 2026-05-04
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = '0072'
down_revision: Union[str, None] = '0071'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'jupiter_payments',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True),
sa.Column('order_id', sa.String(64), unique=True, nullable=False, index=True),
sa.Column('jupiter_transaction_id', sa.String(128), unique=True, nullable=True, index=True),
sa.Column('amount_kopeks', sa.Integer(), nullable=False),
sa.Column('currency', sa.String(10), nullable=False, server_default='RUB'),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('status', sa.String(32), nullable=False, server_default='pending'),
sa.Column('is_paid', sa.Boolean(), server_default=sa.text('false'), nullable=False),
sa.Column('payment_url', sa.Text(), nullable=True),
sa.Column('payment_method', sa.String(32), nullable=True),
sa.Column('metadata_json', sa.JSON(), nullable=True),
sa.Column('callback_payload', sa.JSON(), nullable=True),
sa.Column('paid_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column('transaction_id', sa.Integer(), sa.ForeignKey('transactions.id'), nullable=True),
)
def downgrade() -> None:
op.drop_table('jupiter_payments')
@@ -0,0 +1,45 @@
"""create donut_payments table
Revision ID: 0073
Revises: 0072
Create Date: 2026-05-04
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = '0073'
down_revision: Union[str, None] = '0072'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'donut_payments',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True),
sa.Column('order_id', sa.String(64), unique=True, nullable=False, index=True),
sa.Column('donut_transaction_id', sa.String(128), unique=True, nullable=True, index=True),
sa.Column('amount_kopeks', sa.Integer(), nullable=False),
sa.Column('currency', sa.String(10), nullable=False, server_default='RUB'),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('status', sa.String(32), nullable=False, server_default='pending'),
sa.Column('is_paid', sa.Boolean(), server_default=sa.text('false'), nullable=False),
sa.Column('payment_url', sa.Text(), nullable=True),
sa.Column('payment_method', sa.String(32), nullable=True),
sa.Column('metadata_json', sa.JSON(), nullable=True),
sa.Column('callback_payload', sa.JSON(), nullable=True),
sa.Column('paid_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column('transaction_id', sa.Integer(), sa.ForeignKey('transactions.id'), nullable=True),
)
def downgrade() -> None:
op.drop_table('donut_payments')