feat: integrate Antilopay payment provider (API v2)

- Add antilopay_service.py with SHA256WithRSA signing (pycryptodome),
  private key for requests, public key for callback verification
- Add payment mixin with create/callback/finalize/check_status flows,
  kopeks↔rubles conversion, 7 status mappings, prefer_methods support
- Add CRUD with FOR UPDATE locking, idempotency checks
- Add handlers with SBP/Card/SberPay sub-method selection
- Add Alembic migration for antilopay_payments table
- Add config (ANTILOPAY_ENABLED, SECRET_ID, PRIVATE_KEY, PUBLIC_KEY,
  PROJECT_ID, SBP/CARD/SBERPAY enabled/display names)
- Add webhook endpoint with X-Apay-Callback header signature verification
- Register in keyboard, router, utils, backup, method config
This commit is contained in:
Fringg
2026-05-04 07:44:17 +03:00
parent 6524f66da2
commit 719664208e
15 changed files with 1692 additions and 0 deletions
+68
View File
@@ -686,6 +686,28 @@ class Settings(BaseSettings):
AURAPAY_CARD_ENABLED: bool = False
AURAPAY_CARD_DISPLAY_NAME: str = 'Карта (AuraPay)'
# Antilopay (lk.antilopay.com)
ANTILOPAY_ENABLED: bool = False
ANTILOPAY_SECRET_ID: str | None = None
ANTILOPAY_PRIVATE_KEY: str | None = None
ANTILOPAY_PUBLIC_KEY: str | None = None
ANTILOPAY_PROJECT_ID: str | None = None
ANTILOPAY_DISPLAY_NAME: str = 'Antilopay'
ANTILOPAY_PRODUCT_NAME: str = 'VPN подписка'
ANTILOPAY_PRODUCT_TYPE: str = 'services'
ANTILOPAY_CURRENCY: str = 'RUB'
ANTILOPAY_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
ANTILOPAY_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
ANTILOPAY_WEBHOOK_PATH: str = '/antilopay-webhook'
ANTILOPAY_RETURN_URL: str | None = None
ANTILOPAY_PAYMENT_LIFETIME_MINUTES: int = 60
ANTILOPAY_SBP_ENABLED: bool = False
ANTILOPAY_SBP_DISPLAY_NAME: str = 'СБП (Antilopay)'
ANTILOPAY_CARD_ENABLED: bool = False
ANTILOPAY_CARD_DISPLAY_NAME: str = 'Карта (Antilopay)'
ANTILOPAY_SBERPAY_ENABLED: bool = False
ANTILOPAY_SBERPAY_DISPLAY_NAME: str = 'SberPay (Antilopay)'
# Etoplatezhi (paymentpage.etoplatezhi.ru)
ETOPLATEZHI_ENABLED: bool = False
ETOPLATEZHI_PROJECT_ID: int | None = None
@@ -2158,6 +2180,52 @@ class Settings(BaseSettings):
def get_aurapay_card_display_name_html(self) -> str:
return html.escape(self.get_aurapay_card_display_name())
def is_antilopay_enabled(self) -> bool:
return (
self.ANTILOPAY_ENABLED
and self.ANTILOPAY_SECRET_ID is not None
and self.ANTILOPAY_PRIVATE_KEY is not None
and self.ANTILOPAY_PUBLIC_KEY is not None
and self.ANTILOPAY_PROJECT_ID is not None
)
def get_antilopay_display_name(self) -> str:
name = (self.ANTILOPAY_DISPLAY_NAME or '').strip()
return name if name else 'Antilopay'
def get_antilopay_display_name_html(self) -> str:
return html.escape(self.get_antilopay_display_name())
def is_antilopay_sbp_enabled(self) -> bool:
return self.ANTILOPAY_SBP_ENABLED and self.is_antilopay_enabled()
def get_antilopay_sbp_display_name(self) -> str:
name = (self.ANTILOPAY_SBP_DISPLAY_NAME or '').strip()
return name or 'СБП (Antilopay)'
def get_antilopay_sbp_display_name_html(self) -> str:
return html.escape(self.get_antilopay_sbp_display_name())
def is_antilopay_card_enabled(self) -> bool:
return self.ANTILOPAY_CARD_ENABLED and self.is_antilopay_enabled()
def get_antilopay_card_display_name(self) -> str:
name = (self.ANTILOPAY_CARD_DISPLAY_NAME or '').strip()
return name or 'Карта (Antilopay)'
def get_antilopay_card_display_name_html(self) -> str:
return html.escape(self.get_antilopay_card_display_name())
def is_antilopay_sberpay_enabled(self) -> bool:
return self.ANTILOPAY_SBERPAY_ENABLED and self.is_antilopay_enabled()
def get_antilopay_sberpay_display_name(self) -> str:
name = (self.ANTILOPAY_SBERPAY_DISPLAY_NAME or '').strip()
return name or 'SberPay (Antilopay)'
def get_antilopay_sberpay_display_name_html(self) -> str:
return html.escape(self.get_antilopay_sberpay_display_name())
def is_etoplatezhi_enabled(self) -> bool:
return (
self.ETOPLATEZHI_ENABLED
+161
View File
@@ -0,0 +1,161 @@
"""CRUD операции для платежей Antilopay."""
from datetime import UTC, datetime
import structlog
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import AntilopayPayment
logger = structlog.get_logger(__name__)
async def create_antilopay_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,
antilopay_payment_id: str | None = None,
expires_at: datetime | None = None,
metadata_json: dict | None = None,
) -> AntilopayPayment:
"""Создает запись о платеже Antilopay."""
payment = AntilopayPayment(
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
payment_method=payment_method,
antilopay_payment_id=antilopay_payment_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('Создан платеж Antilopay', order_id=order_id, user_id=user_id)
return payment
async def get_antilopay_payment_by_order_id(db: AsyncSession, order_id: str) -> AntilopayPayment | None:
"""Получает платеж по order_id (internal)."""
result = await db.execute(select(AntilopayPayment).where(AntilopayPayment.order_id == order_id))
return result.scalar_one_or_none()
async def get_antilopay_payment_by_invoice_id(
db: AsyncSession, antilopay_payment_id: str
) -> AntilopayPayment | None:
"""Получает платеж по ID от Antilopay."""
result = await db.execute(
select(AntilopayPayment).where(AntilopayPayment.antilopay_payment_id == antilopay_payment_id)
)
return result.scalar_one_or_none()
async def get_antilopay_payment_by_id(db: AsyncSession, payment_id: int) -> AntilopayPayment | None:
"""Получает платеж по ID."""
result = await db.execute(select(AntilopayPayment).where(AntilopayPayment.id == payment_id))
return result.scalar_one_or_none()
async def get_antilopay_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> AntilopayPayment | None:
"""Получает платеж по ID с блокировкой FOR UPDATE."""
result = await db.execute(
select(AntilopayPayment)
.where(AntilopayPayment.id == payment_id)
.with_for_update()
.execution_options(populate_existing=True)
)
return result.scalar_one_or_none()
async def update_antilopay_payment_status(
db: AsyncSession,
payment: AntilopayPayment,
*,
status: str,
is_paid: bool | None = None,
antilopay_payment_id: str | None = None,
payment_method: str | None = None,
callback_payload: dict | None = None,
transaction_id: int | None = None,
) -> AntilopayPayment:
"""Обновляет статус платежа."""
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 antilopay_payment_id is not None:
payment.antilopay_payment_id = antilopay_payment_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(
'Обновлен статус платежа Antilopay',
order_id=payment.order_id,
status=status,
is_paid=payment.is_paid,
)
return payment
async def get_pending_antilopay_payments(db: AsyncSession, user_id: int) -> list[AntilopayPayment]:
"""Получает незавершенные платежи пользователя."""
result = await db.execute(
select(AntilopayPayment).where(
AntilopayPayment.user_id == user_id,
AntilopayPayment.status == 'pending',
AntilopayPayment.is_paid == False,
)
)
return list(result.scalars().all())
async def get_expired_pending_antilopay_payments(
db: AsyncSession,
) -> list[AntilopayPayment]:
"""Получает просроченные платежи в статусе pending."""
now = datetime.now(UTC)
result = await db.execute(
select(AntilopayPayment).where(
AntilopayPayment.status == 'pending',
AntilopayPayment.is_paid == False,
AntilopayPayment.expires_at < now,
)
)
return list(result.scalars().all())
async def link_antilopay_payment_to_transaction(
db: AsyncSession,
*,
payment: AntilopayPayment,
transaction_id: int,
) -> AntilopayPayment:
"""Связывает платеж с транзакцией."""
payment.transaction_id = transaction_id
payment.updated_at = datetime.now(UTC)
await db.flush()
await db.refresh(payment)
return payment
+1
View File
@@ -32,6 +32,7 @@ REAL_PAYMENT_METHODS = [
PaymentMethod.OVERPAY.value,
PaymentMethod.AURAPAY.value,
PaymentMethod.ETOPLATEZHI.value,
PaymentMethod.ANTILOPAY.value,
]
+63
View File
@@ -168,6 +168,7 @@ class PaymentMethod(Enum):
OVERPAY = 'overpay'
AURAPAY = 'aurapay'
ETOPLATEZHI = 'etoplatezhi'
ANTILOPAY = 'antilopay'
MANUAL = 'manual'
BALANCE = 'balance'
@@ -1229,6 +1230,68 @@ class EtoplatezhiPayment(Base):
return f'<EtoplatezhiPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
class AntilopayPayment(Base):
"""Платежи через Antilopay (lk.antilopay.com)."""
__tablename__ = 'antilopay_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
antilopay_payment_id = Column(String(128), unique=True, nullable=True, index=True) # ID от Antilopay (APAY...)
# Суммы
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)
payment_method = Column(String(32), nullable=True)
# Метаданные
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='antilopay_payments')
transaction = relationship('Transaction', backref='antilopay_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', 'canceled', 'amount_mismatch']
def __repr__(self) -> str: # pragma: no cover - debug helper
return f'<AntilopayPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
class PromoGroup(Base):
__tablename__ = 'promo_groups'
+315
View File
@@ -0,0 +1,315 @@
"""Handler for Antilopay balance top-up."""
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__)
def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | None:
"""Проверяет ограничение на пополнение. Возвращает клавиатуру если ограничен, иначе 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_antilopay_payment_and_respond(
message_or_callback,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
edit_message: bool = False,
payment_method_type: str | None = None,
):
"""
Common logic for creating Antilopay payment and sending response.
"""
texts = get_texts(db_user.language)
amount_rub = amount_kopeks / 100
# Create payment
payment_service = PaymentService()
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
service_name=settings.PAYMENT_SERVICE_NAME,
description='Пополнение баланса',
)
result = await payment_service.create_antilopay_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_antilopay_display_name()
# Create keyboard with payment button
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t(
'PAY_BUTTON',
'\U0001f4b3 Оплатить {amount}\u20bd',
).format(amount=f'{amount_rub:.0f}'),
url=payment_url,
)
],
[
InlineKeyboardButton(
text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'),
callback_data='menu_balance',
)
],
]
)
response_text = texts.t(
'ANTILOPAY_PAYMENT_CREATED',
'\U0001f4b3 <b>Оплата через {name}</b>\n\n'
'Сумма: <b>{amount}\u20bd</b>\n\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('Antilopay payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
@error_handler
async def process_antilopay_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
):
"""
Process payment amount directly.
"""
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
# Validate amount
min_amount = settings.ANTILOPAY_MIN_AMOUNT_KOPEKS
max_amount = settings.ANTILOPAY_MAX_AMOUNT_KOPEKS
if amount_kopeks < min_amount:
await message.answer(
texts.t(
'PAYMENT_AMOUNT_TOO_LOW',
'Минимальная сумма пополнения: {min_amount}\u20bd',
).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}\u20bd',
).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', 'antilopay')
# antilopay_sbp → 'sbp', antilopay_card → 'card', antilopay_sberpay → 'sberpay', antilopay → None
payment_method_type = _extract_service_type(payment_method)
await state.clear()
await _create_antilopay_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,
)
ANTILOPAY_PAYMENT_METHODS = {'antilopay', 'antilopay_sbp', 'antilopay_card', 'antilopay_sberpay'}
ANTILOPAY_SERVICE_MAP: dict[str, str | None] = {
'antilopay': None,
'antilopay_sbp': 'sbp',
'antilopay_card': 'card',
'antilopay_sberpay': 'sberpay',
}
def _extract_service_type(payment_method: str) -> str | None:
return ANTILOPAY_SERVICE_MAP.get(payment_method)
async def _start_antilopay_topup_impl(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
payment_method: str,
):
"""Common logic for starting Antilopay top-up (generic / SBP / card / SberPay)."""
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.ANTILOPAY_MIN_AMOUNT_KOPEKS // 100
max_amount = settings.ANTILOPAY_MAX_AMOUNT_KOPEKS // 100
# Choose display name based on sub-method
if payment_method == 'antilopay_sbp':
display_name = settings.get_antilopay_sbp_display_name()
elif payment_method == 'antilopay_card':
display_name = settings.get_antilopay_card_display_name()
elif payment_method == 'antilopay_sberpay':
display_name = settings.get_antilopay_sberpay_display_name()
else:
display_name = settings.get_antilopay_display_name()
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t('BACK_BUTTON', '\u25c0\ufe0f Назад'),
callback_data='menu_balance',
)
]
]
)
await callback.message.edit_text(
texts.t(
'ANTILOPAY_ENTER_AMOUNT',
'\U0001f4b3 <b>Пополнение через {name}</b>\n\n'
'Введите сумму пополнения в рублях.\n\n'
'Минимум: {min_amount}\u20bd\n'
'Максимум: {max_amount}\u20bd',
).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_antilopay_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _start_antilopay_topup_impl(callback, db_user, state, 'antilopay')
@error_handler
async def start_antilopay_sbp_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _start_antilopay_topup_impl(callback, db_user, state, 'antilopay_sbp')
@error_handler
async def start_antilopay_card_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _start_antilopay_topup_impl(callback, db_user, state, 'antilopay_card')
@error_handler
async def start_antilopay_sberpay_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await _start_antilopay_topup_impl(callback, db_user, state, 'antilopay_sberpay')
+19
View File
@@ -184,6 +184,13 @@ async def route_payment_by_method(
await process_etoplatezhi_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method in ('antilopay', 'antilopay_sbp', 'antilopay_card', 'antilopay_sberpay'):
from .antilopay import process_antilopay_payment_amount
async with AsyncSessionLocal() as db:
await process_antilopay_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method == 'riopay':
from .riopay import process_riopay_payment_amount
@@ -787,6 +794,18 @@ def register_balance_handlers(dp: Dispatcher):
dp.callback_query.register(start_etoplatezhi_sbp_topup, F.data == 'topup_etoplatezhi_sbp')
dp.callback_query.register(start_etoplatezhi_card_topup, F.data == 'topup_etoplatezhi_card')
from .antilopay import (
start_antilopay_card_topup,
start_antilopay_sberpay_topup,
start_antilopay_sbp_topup,
start_antilopay_topup,
)
dp.callback_query.register(start_antilopay_topup, F.data == 'topup_antilopay')
dp.callback_query.register(start_antilopay_sbp_topup, F.data == 'topup_antilopay_sbp')
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 .mulenpay import check_mulenpay_payment_status
dp.callback_query.register(check_mulenpay_payment_status, F.data.startswith('check_mulenpay_'))
+53
View File
@@ -1930,6 +1930,59 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
)
has_direct_payment_methods = True
if settings.is_antilopay_sbp_enabled():
sbp_name = settings.get_antilopay_sbp_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_ANTILOPAY_SBP', f'📱 {sbp_name}'),
callback_data=_build_callback('antilopay_sbp'),
)
]
)
has_direct_payment_methods = True
if settings.is_antilopay_card_enabled():
card_name = settings.get_antilopay_card_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_ANTILOPAY_CARD', f'💳 {card_name}'),
callback_data=_build_callback('antilopay_card'),
)
]
)
has_direct_payment_methods = True
if settings.is_antilopay_sberpay_enabled():
sberpay_name = settings.get_antilopay_sberpay_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_ANTILOPAY_SBERPAY', f'💳 {sberpay_name}'),
callback_data=_build_callback('antilopay_sberpay'),
)
]
)
has_direct_payment_methods = True
if (
settings.is_antilopay_enabled()
and not settings.is_antilopay_sbp_enabled()
and not settings.is_antilopay_card_enabled()
and not settings.is_antilopay_sberpay_enabled()
):
antilopay_name = settings.get_antilopay_display_name()
keyboard.append(
[
InlineKeyboardButton(
text=texts.t('PAYMENT_ANTILOPAY', f'💳 {antilopay_name}'),
callback_data=_build_callback('antilopay'),
)
]
)
has_direct_payment_methods = True
if settings.is_support_topup_enabled():
keyboard.append(
[
+246
View File
@@ -0,0 +1,246 @@
"""Сервис для работы с API Antilopay (lk.antilopay.com/api/v2)."""
import base64
import json
from typing import Any
import aiohttp
import structlog
from Crypto.Hash import SHA256
from Crypto.PublicKey import RSA
from Crypto.Signature import pkcs1_15
from app.config import settings
logger = structlog.get_logger(__name__)
API_BASE_URL = 'https://lk.antilopay.com/api/v2'
class AntilopayAPIError(Exception):
"""Ошибка API Antilopay."""
def __init__(self, status_code: int, message: str, code: int | None = None):
self.status_code = status_code
self.message = message
self.api_code = code
super().__init__(f'Antilopay API error ({status_code}): {message}')
class AntilopayService:
"""Сервис для работы с API Antilopay."""
def __init__(self) -> None:
self._session: aiohttp.ClientSession | None = None
@property
def secret_id(self) -> str:
return settings.ANTILOPAY_SECRET_ID or ''
@property
def private_key(self) -> str:
return settings.ANTILOPAY_PRIVATE_KEY or ''
@property
def public_key(self) -> str:
return settings.ANTILOPAY_PUBLIC_KEY or ''
@property
def project_id(self) -> str:
return settings.ANTILOPAY_PROJECT_ID or ''
async def _get_session(self) -> aiohttp.ClientSession:
"""Возвращает переиспользуемую HTTP-сессию."""
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:
"""Закрывает HTTP-сессию."""
if self._session and not self._session.closed:
await self._session.close()
self._session = None
def _sign_request(self, json_body: str) -> str:
"""SHA256WithRSA подпись JSON body приватным ключом.
Результат base64-encoded строка.
"""
rsa_key = RSA.import_key(base64.b64decode(self.private_key))
h = SHA256.new(json_body.encode('UTF-8'))
signature = pkcs1_15.new(rsa_key).sign(h)
return base64.b64encode(signature).decode('UTF-8')
def _build_headers(self, json_body: str) -> dict[str, str]:
"""Строит заголовки запроса с подписью."""
return {
'Content-Type': 'application/json',
'X-Apay-Secret-Id': self.secret_id,
'X-Apay-Sign': self._sign_request(json_body),
'X-Apay-Sign-Version': '1',
}
async def create_payment(
self,
*,
amount_rubles: float,
order_id: str,
product_name: str,
product_type: str = 'services',
description: str = '',
customer_email: str | None = None,
customer_phone: str | None = None,
prefer_methods: list[str] | None = None,
success_url: str | None = None,
fail_url: str | None = None,
merchant_extra: str | None = None,
) -> dict[str, Any]:
"""
Создает платеж через API Antilopay.
POST /payment/create
"""
payload: dict[str, Any] = {
'project_identificator': self.project_id,
'amount': amount_rubles,
'order_id': order_id,
'currency': settings.ANTILOPAY_CURRENCY.lower(),
'product_name': product_name,
'product_type': product_type,
'description': description,
}
# customer — обязательное поле, нужен email или phone
customer: dict[str, str] = {}
if customer_email:
customer['email'] = customer_email
if customer_phone:
customer['phone'] = customer_phone
if not customer:
# Fallback email, чтобы API не отказал
customer['email'] = 'user@vpn.bot'
payload['customer'] = customer
if prefer_methods:
payload['prefer_methods'] = prefer_methods
if success_url:
payload['success_url'] = success_url
if fail_url:
payload['fail_url'] = fail_url
if merchant_extra:
payload['merchant_extra'] = merchant_extra[:255]
json_body = json.dumps(payload, separators=(',', ':'), ensure_ascii=False)
logger.info(
'Antilopay API create_payment',
order_id=order_id,
amount_rubles=amount_rubles,
prefer_methods=prefer_methods,
)
try:
session = await self._get_session()
async with session.post(
f'{API_BASE_URL}/payment/create',
data=json_body,
headers=self._build_headers(json_body),
) as response:
data = await response.json(content_type=None)
api_code = data.get('code')
if response.status == 200 and api_code == 0:
logger.info(
'Antilopay API payment created',
order_id=order_id,
payment_id=data.get('payment_id'),
payment_url=data.get('payment_url'),
)
return data
error_msg = data.get('message') or data.get('error') or str(data)
logger.error(
'Antilopay create_payment error',
status_code=response.status,
api_code=api_code,
error_msg=error_msg,
response_data=data,
)
raise AntilopayAPIError(response.status, error_msg, api_code)
except aiohttp.ClientError as e:
logger.exception('Antilopay API connection error', error=e)
raise
async def check_payment(
self,
*,
order_id: str,
) -> dict[str, Any]:
"""
Проверяет статус платежа.
POST /payment/check
"""
payload: dict[str, Any] = {
'project_identificator': self.project_id,
'order_id': order_id,
}
json_body = json.dumps(payload, separators=(',', ':'), ensure_ascii=False)
logger.info('Antilopay check_payment', order_id=order_id)
try:
session = await self._get_session()
async with session.post(
f'{API_BASE_URL}/payment/check',
data=json_body,
headers=self._build_headers(json_body),
) as response:
data = await response.json(content_type=None)
if response.status == 200:
return data
error_msg = data.get('message') or data.get('error') or str(data)
logger.error(
'Antilopay check_payment error',
status_code=response.status,
error_msg=error_msg,
)
raise AntilopayAPIError(response.status, error_msg)
except aiohttp.ClientError as e:
logger.exception('Antilopay API connection error', error=e)
raise
def verify_callback_signature(self, raw_body: bytes, received_signature: str) -> bool:
"""Верификация подписи callback Antilopay через SHA256WithRSA.
Подпись приходит в заголовке X-Apay-Callback.
Проверяется ПУБЛИЧНЫМ ключом.
"""
try:
if not received_signature:
logger.warning('Antilopay callback: отсутствует X-Apay-Callback')
return False
rsa_key = RSA.import_key(base64.b64decode(self.public_key))
h = SHA256.new(raw_body)
signature_bytes = base64.b64decode(received_signature)
pkcs1_15.new(rsa_key).verify(h, signature_bytes)
return True
except (ValueError, TypeError) as e:
logger.warning('Antilopay callback: invalid signature', error=str(e))
return False
except Exception as e:
logger.error('Antilopay callback verify error', error=e)
return False
# Singleton instance
antilopay_service = AntilopayService()
+1
View File
@@ -1518,6 +1518,7 @@ class BackupService:
'overpay_payments',
'aurapay_payments',
'etoplatezhi_payments',
'antilopay_payments',
'apple_transactions',
'saved_payment_methods',
# --- Content/config ---
+533
View File
@@ -0,0 +1,533 @@
"""Mixin для интеграции с Antilopay (lk.antilopay.com)."""
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.antilopay_service import antilopay_service
from app.utils.payment_logger import payment_logger as logger
from app.utils.user_utils import format_referrer_info
# Маппинг статусов Antilopay -> internal
ANTILOPAY_STATUS_MAP: dict[str, tuple[str, bool]] = {
'PENDING': ('pending', False),
'SUCCESS': ('success', True),
'FAIL': ('failed', False),
'CANCEL': ('cancelled', False),
'EXPIRED': ('expired', False),
'CHARGEBACK': ('chargeback', False),
'REVERSED': ('reversed', False),
}
class AntilopayPaymentMixin:
"""Mixin для работы с платежами Antilopay."""
async def create_antilopay_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:
"""
Создает платеж Antilopay.
Returns:
Словарь с данными платежа или None при ошибке
"""
if not settings.is_antilopay_enabled():
logger.error('Antilopay не настроен')
return None
# Валидация лимитов
if amount_kopeks < settings.ANTILOPAY_MIN_AMOUNT_KOPEKS:
logger.warning(
'Antilopay: сумма меньше минимальной',
amount_kopeks=amount_kopeks,
ANTILOPAY_MIN_AMOUNT_KOPEKS=settings.ANTILOPAY_MIN_AMOUNT_KOPEKS,
)
return None
if amount_kopeks > settings.ANTILOPAY_MAX_AMOUNT_KOPEKS:
logger.warning(
'Antilopay: сумма больше максимальной',
amount_kopeks=amount_kopeks,
ANTILOPAY_MAX_AMOUNT_KOPEKS=settings.ANTILOPAY_MAX_AMOUNT_KOPEKS,
)
return None
# Получаем telegram_id пользователя для order_id
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 с telegram_id для удобного поиска
order_id = f'alp{tg_id}_{uuid.uuid4().hex[:6]}'
amount_rubles = amount_kopeks / 100
currency = settings.ANTILOPAY_CURRENCY
# Метаданные
metadata = {
'user_id': user_id,
'amount_kopeks': amount_kopeks,
'description': description,
'language': language,
'type': 'balance_topup',
}
try:
# Определяем prefer_methods по типу подметода
prefer_methods: list[str] | None = None
if payment_method_type == 'sbp':
prefer_methods = ['SBP']
elif payment_method_type == 'card':
prefer_methods = ['CARD_RU']
elif payment_method_type == 'sberpay':
prefer_methods = ['SBER_PAY']
# Формируем success/fail URL
result_url = return_url or settings.ANTILOPAY_RETURN_URL
# merchant_extra — строка до 255 символов для callback
merchant_extra = order_id
# Создаем платеж через API
api_result = await antilopay_service.create_payment(
amount_rubles=amount_rubles,
order_id=order_id,
product_name=settings.ANTILOPAY_PRODUCT_NAME,
product_type=settings.ANTILOPAY_PRODUCT_TYPE,
description=description,
customer_email=email,
prefer_methods=prefer_methods,
success_url=result_url,
fail_url=result_url,
merchant_extra=merchant_extra,
)
payment_id = api_result.get('payment_id')
payment_url = api_result.get('payment_url')
logger.info(
'Antilopay: получен ответ API',
order_id=order_id,
payment_id=payment_id,
payment_url=payment_url,
)
lifetime = settings.ANTILOPAY_PAYMENT_LIFETIME_MINUTES
expires_at = datetime.now(UTC) + timedelta(minutes=lifetime)
# Сохраняем в БД
antilopay_crud = import_module('app.database.crud.antilopay')
local_payment = await antilopay_crud.create_antilopay_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,
antilopay_payment_id=payment_id,
expires_at=expires_at,
metadata_json=metadata,
)
logger.info(
'Antilopay: создан платеж',
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': payment_id,
'expires_at': expires_at.isoformat(),
'local_payment_id': local_payment.id,
}
except Exception as e:
logger.exception('Antilopay: ошибка создания платежа', error=e)
return None
async def process_antilopay_callback(
self,
db: AsyncSession,
payload: dict[str, Any],
) -> bool:
"""
Обрабатывает callback от Antilopay.
Подпись проверяется в webserver/payments.py до вызова этого метода.
Args:
db: Сессия БД
payload: JSON тело callback (signature проверена в webserver)
Returns:
True если платеж успешно обработан
"""
try:
callback_type = payload.get('type')
if callback_type != 'payment':
logger.info('Antilopay callback: неизвестный тип', callback_type=callback_type)
return True # Не наш тип — не ошибка
antilopay_payment_id = payload.get('payment_id')
antilopay_status = payload.get('status')
our_order_id = payload.get('order_id')
if not our_order_id or not antilopay_status:
logger.warning('Antilopay callback: отсутствуют обязательные поля', payload=payload)
return False
# Определяем is_paid по статусу
is_confirmed = antilopay_status == 'SUCCESS'
# Ищем платеж по order_id
antilopay_crud = import_module('app.database.crud.antilopay')
payment = await antilopay_crud.get_antilopay_payment_by_order_id(db, our_order_id)
if not payment:
logger.warning(
'Antilopay callback: платеж не найден',
order_id=our_order_id,
)
return False
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
locked = await antilopay_crud.get_antilopay_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('Antilopay: не удалось заблокировать платёж', payment_id=payment.id)
return False
payment = locked
# Проверка дублирования (re-check from locked row)
if payment.is_paid:
logger.info('Antilopay callback: платеж уже обработан', order_id=payment.order_id)
return True
# Маппинг статуса
status_info = ANTILOPAY_STATUS_MAP.get(antilopay_status, ('pending', False))
internal_status, is_paid = status_info
# Если статус SUCCESS, принудительно считаем оплаченным
if is_confirmed:
is_paid = True
internal_status = 'success'
callback_payload = {
'antilopay_payment_id': antilopay_payment_id,
'status': antilopay_status,
'amount': payload.get('amount'),
'original_amount': payload.get('original_amount'),
'fee': payload.get('fee'),
'currency': payload.get('currency'),
'pay_method': payload.get('pay_method'),
'pay_data': payload.get('pay_data'),
'customer': payload.get('customer'),
'merchant_extra': payload.get('merchant_extra'),
}
# Проверка суммы ДО обновления статуса
if is_paid:
original_amount = payload.get('original_amount')
if original_amount is not None:
# original_amount в РУБЛЯХ (float), конвертируем в копейки
received_kopeks = round(float(original_amount) * 100)
if abs(received_kopeks - payment.amount_kopeks) > 1:
logger.error(
'Antilopay amount mismatch',
expected_kopeks=payment.amount_kopeks,
received_kopeks=received_kopeks,
order_id=payment.order_id,
)
await antilopay_crud.update_antilopay_payment_status(
db=db,
payment=payment,
status='amount_mismatch',
is_paid=False,
callback_payload=callback_payload,
)
return False
# Финализируем платеж если оплачен — без промежуточного commit
if is_paid:
# Inline field assignments to keep FOR UPDATE lock intact
payment.status = internal_status
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.antilopay_payment_id = str(antilopay_payment_id) if antilopay_payment_id else None
payment.callback_payload = callback_payload
payment.updated_at = datetime.now(UTC)
await db.flush()
return await self._finalize_antilopay_payment(db, payment, trigger='webhook')
# Для не-success статусов можно безопасно коммитить
payment = await antilopay_crud.update_antilopay_payment_status(
db=db,
payment=payment,
status=internal_status,
is_paid=False,
callback_payload=callback_payload,
)
return True
except Exception as e:
logger.exception('Antilopay callback: ошибка обработки', error=e)
return False
async def _finalize_antilopay_payment(
self,
db: AsyncSession,
payment: Any,
*,
trigger: str,
) -> bool:
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления.
FOR UPDATE lock must be acquired by the caller before invoking this method.
"""
payment_module = import_module('app.services.payment_service')
antilopay_crud = import_module('app.database.crud.antilopay')
# FOR UPDATE lock already acquired by caller — just check idempotency
if payment.transaction_id:
logger.info(
'Antilopay платеж уже связан с транзакцией',
order_id=payment.order_id,
transaction_id=payment.transaction_id,
trigger=trigger,
)
return True
# Read fresh metadata AFTER lock to avoid stale data
metadata = dict(getattr(payment, 'metadata_json', {}) or {})
# --- Guest purchase flow ---
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='antilopay',
)
if guest_result is not None:
return True
# Ensure paid fields are set (idempotent — caller may have already set them)
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('Пользователь не найден для Antilopay', 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.ANTILOPAY,
)
display_name = settings.get_antilopay_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.ANTILOPAY,
external_id=transaction_external_id,
is_completed=True,
created_at=getattr(payment, 'created_at', None),
commit=False,
)
created_transaction = True
await antilopay_crud.link_antilopay_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('Antilopay платеж уже зачислил баланс ранее', order_id=payment.order_id)
return True
# Lock user row to prevent concurrent balance race conditions
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)
# Emit deferred side-effects after atomic commit
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.ANTILOPAY,
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('Ошибка обработки реферального пополнения Antilopay', 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('Ошибка отправки админ уведомления Antilopay', 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,
(
'\u2705 <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('Ошибка отправки уведомления пользователю Antilopay', 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(
'Обработан Antilopay платеж',
order_id=payment.order_id,
user_id=payment.user_id,
trigger=trigger,
)
return True
async def check_antilopay_payment_status(
self,
db: AsyncSession,
order_id: str,
) -> dict[str, Any] | None:
"""Проверяет статус платежа через API Antilopay."""
try:
result = await antilopay_service.check_payment(order_id=order_id)
return result
except Exception as e:
logger.error('Antilopay: ошибка проверки статуса', order_id=order_id, error=e)
return None
@@ -199,6 +199,17 @@ def _get_method_defaults() -> dict:
{'id': 'sbp', 'name': 'СБП'},
],
},
'antilopay': {
'default_display_name': settings.get_antilopay_display_name(),
'is_configured': settings.is_antilopay_enabled(),
'default_min': settings.ANTILOPAY_MIN_AMOUNT_KOPEKS,
'default_max': settings.ANTILOPAY_MAX_AMOUNT_KOPEKS,
'available_sub_options': [
{'id': 'card', 'name': 'Карта'},
{'id': 'sbp', 'name': 'СБП'},
{'id': 'sberpay', 'name': 'SberPay'},
],
},
}
@@ -246,6 +257,7 @@ DEFAULT_METHOD_ORDER = [
'overpay',
'aurapay',
'etoplatezhi',
'antilopay',
]
+59
View File
@@ -30,6 +30,7 @@ from app.services.payment import (
WataPaymentMixin,
YooKassaPaymentMixin,
)
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.etoplatezhi import EtoplatezhiPaymentMixin
@@ -518,6 +519,41 @@ async def link_etoplatezhi_payment_to_transaction(*args, **kwargs):
return await etoplatezhi_crud.link_etoplatezhi_payment_to_transaction(*args, **kwargs)
async def create_antilopay_payment(*args, **kwargs):
antilopay_crud = import_module('app.database.crud.antilopay')
return await antilopay_crud.create_antilopay_payment(*args, **kwargs)
async def get_antilopay_payment_by_order_id(*args, **kwargs):
antilopay_crud = import_module('app.database.crud.antilopay')
return await antilopay_crud.get_antilopay_payment_by_order_id(*args, **kwargs)
async def get_antilopay_payment_by_invoice_id(*args, **kwargs):
antilopay_crud = import_module('app.database.crud.antilopay')
return await antilopay_crud.get_antilopay_payment_by_invoice_id(*args, **kwargs)
async def get_antilopay_payment_by_id(*args, **kwargs):
antilopay_crud = import_module('app.database.crud.antilopay')
return await antilopay_crud.get_antilopay_payment_by_id(*args, **kwargs)
async def get_antilopay_payment_by_id_for_update(*args, **kwargs):
antilopay_crud = import_module('app.database.crud.antilopay')
return await antilopay_crud.get_antilopay_payment_by_id_for_update(*args, **kwargs)
async def update_antilopay_payment_status(*args, **kwargs):
antilopay_crud = import_module('app.database.crud.antilopay')
return await antilopay_crud.update_antilopay_payment_status(*args, **kwargs)
async def link_antilopay_payment_to_transaction(*args, **kwargs):
antilopay_crud = import_module('app.database.crud.antilopay')
return await antilopay_crud.link_antilopay_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] = {
@@ -546,6 +582,7 @@ class PaymentService(
OverpayPaymentMixin,
AuraPayPaymentMixin,
EtoplatezhiPaymentMixin,
AntilopayPaymentMixin,
):
"""Основной интерфейс платежей, делегирующий работу специализированным mixin-ам."""
@@ -1075,6 +1112,28 @@ class PaymentService(
}
return None
# --- Antilopay --------------------------------------------------------
if payment_method == 'antilopay':
if not settings.is_antilopay_enabled():
logger.warning('Antilopay is not enabled, cannot create guest payment')
return None
result = await self.create_antilopay_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'], 'antilopay')
return {
'payment_url': result.get('payment_url'),
'payment_id': result.get('order_id'),
'provider': 'antilopay',
}
return None
# --- Telegram Stars ---------------------------------------------------
if payment_method == 'telegram_stars':
if not settings.TELEGRAM_STARS_ENABLED:
+67
View File
@@ -312,6 +312,59 @@ def get_available_payment_methods() -> list[dict[str, str]]:
}
)
if settings.is_antilopay_sbp_enabled():
sbp_name = settings.get_antilopay_sbp_display_name()
methods.append(
{
'id': 'antilopay_sbp',
'name': sbp_name,
'icon': '📱',
'description': f'через {sbp_name}',
'callback': 'topup_antilopay_sbp',
}
)
if settings.is_antilopay_card_enabled():
card_name = settings.get_antilopay_card_display_name()
methods.append(
{
'id': 'antilopay_card',
'name': card_name,
'icon': '💳',
'description': f'через {card_name}',
'callback': 'topup_antilopay_card',
}
)
if settings.is_antilopay_sberpay_enabled():
sberpay_name = settings.get_antilopay_sberpay_display_name()
methods.append(
{
'id': 'antilopay_sberpay',
'name': sberpay_name,
'icon': '💳',
'description': f'через {sberpay_name}',
'callback': 'topup_antilopay_sberpay',
}
)
if (
settings.is_antilopay_enabled()
and not settings.is_antilopay_sbp_enabled()
and not settings.is_antilopay_card_enabled()
and not settings.is_antilopay_sberpay_enabled()
):
antilopay_name = settings.get_antilopay_display_name()
methods.append(
{
'id': 'antilopay',
'name': antilopay_name,
'icon': '💳',
'description': f'через {antilopay_name}',
'callback': 'topup_antilopay',
}
)
if settings.is_support_topup_enabled():
methods.append(
{
@@ -459,6 +512,14 @@ def is_payment_method_available(method_id: str) -> bool:
return settings.is_etoplatezhi_sbp_enabled()
if method_id == 'etoplatezhi_card':
return settings.is_etoplatezhi_card_enabled()
if method_id == 'antilopay':
return settings.is_antilopay_enabled()
if method_id == 'antilopay_sbp':
return settings.is_antilopay_sbp_enabled()
if method_id == 'antilopay_card':
return settings.is_antilopay_card_enabled()
if method_id == 'antilopay_sberpay':
return settings.is_antilopay_sberpay_enabled()
if method_id == 'support':
return settings.is_support_topup_enabled()
return False
@@ -492,6 +553,10 @@ def get_payment_method_status() -> dict[str, bool]:
'etoplatezhi': settings.is_etoplatezhi_enabled(),
'etoplatezhi_sbp': settings.is_etoplatezhi_sbp_enabled(),
'etoplatezhi_card': settings.is_etoplatezhi_card_enabled(),
'antilopay': settings.is_antilopay_enabled(),
'antilopay_sbp': settings.is_antilopay_sbp_enabled(),
'antilopay_card': settings.is_antilopay_card_enabled(),
'antilopay_sberpay': settings.is_antilopay_sberpay_enabled(),
'support': settings.is_support_topup_enabled(),
}
@@ -539,4 +604,6 @@ def get_enabled_payment_methods_count() -> int:
count += 1
if settings.is_etoplatezhi_enabled():
count += 1
if settings.is_antilopay_enabled():
count += 1
return count
+49
View File
@@ -1516,6 +1516,54 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
routes_registered = True
# Antilopay webhook
if settings.is_antilopay_enabled():
@router.get(settings.ANTILOPAY_WEBHOOK_PATH)
async def antilopay_health() -> JSONResponse:
return JSONResponse(
{
'status': 'ok',
'service': 'antilopay_webhook',
'enabled': settings.is_antilopay_enabled(),
}
)
@router.post(settings.ANTILOPAY_WEBHOOK_PATH)
async def antilopay_webhook(request: Request) -> JSONResponse:
try:
raw_body = await request.body()
payload = json.loads(raw_body)
except Exception as parse_error:
logger.error('Antilopay webhook: failed to parse JSON', parse_error=parse_error)
return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST)
# Подпись в заголовке X-Apay-Callback, верифицируется публичным ключом
from app.services.antilopay_service import antilopay_service
callback_signature = request.headers.get('X-Apay-Callback') or ''
if not antilopay_service.verify_callback_signature(raw_body, callback_signature):
logger.warning('Antilopay webhook: invalid signature')
return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST)
try:
success = await _process_payment_service_callback(
payment_service,
payload,
'process_antilopay_callback',
)
if not success:
logger.error(
'Antilopay webhook processing failed',
data=payload.get('payment_id'),
)
except Exception as e:
logger.exception('Antilopay webhook processing error', error=e)
# Always return 200 — Antilopay retries every 3min for 1hr on non-200
return JSONResponse({'status': True}, status_code=status.HTTP_200_OK)
routes_registered = True
if routes_registered:
@router.get('/health/payment-webhooks')
@@ -1541,6 +1589,7 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
'overpay_enabled': settings.is_overpay_enabled(),
'aurapay_enabled': settings.is_aurapay_enabled(),
'etoplatezhi_enabled': settings.is_etoplatezhi_enabled(),
'antilopay_enabled': settings.is_antilopay_enabled(),
}
)