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:
@@ -686,6 +686,28 @@ class Settings(BaseSettings):
|
|||||||
AURAPAY_CARD_ENABLED: bool = False
|
AURAPAY_CARD_ENABLED: bool = False
|
||||||
AURAPAY_CARD_DISPLAY_NAME: str = 'Карта (AuraPay)'
|
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 (paymentpage.etoplatezhi.ru)
|
||||||
ETOPLATEZHI_ENABLED: bool = False
|
ETOPLATEZHI_ENABLED: bool = False
|
||||||
ETOPLATEZHI_PROJECT_ID: int | None = None
|
ETOPLATEZHI_PROJECT_ID: int | None = None
|
||||||
@@ -2158,6 +2180,52 @@ class Settings(BaseSettings):
|
|||||||
def get_aurapay_card_display_name_html(self) -> str:
|
def get_aurapay_card_display_name_html(self) -> str:
|
||||||
return html.escape(self.get_aurapay_card_display_name())
|
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:
|
def is_etoplatezhi_enabled(self) -> bool:
|
||||||
return (
|
return (
|
||||||
self.ETOPLATEZHI_ENABLED
|
self.ETOPLATEZHI_ENABLED
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -32,6 +32,7 @@ REAL_PAYMENT_METHODS = [
|
|||||||
PaymentMethod.OVERPAY.value,
|
PaymentMethod.OVERPAY.value,
|
||||||
PaymentMethod.AURAPAY.value,
|
PaymentMethod.AURAPAY.value,
|
||||||
PaymentMethod.ETOPLATEZHI.value,
|
PaymentMethod.ETOPLATEZHI.value,
|
||||||
|
PaymentMethod.ANTILOPAY.value,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ class PaymentMethod(Enum):
|
|||||||
OVERPAY = 'overpay'
|
OVERPAY = 'overpay'
|
||||||
AURAPAY = 'aurapay'
|
AURAPAY = 'aurapay'
|
||||||
ETOPLATEZHI = 'etoplatezhi'
|
ETOPLATEZHI = 'etoplatezhi'
|
||||||
|
ANTILOPAY = 'antilopay'
|
||||||
MANUAL = 'manual'
|
MANUAL = 'manual'
|
||||||
BALANCE = 'balance'
|
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})>'
|
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):
|
class PromoGroup(Base):
|
||||||
__tablename__ = 'promo_groups'
|
__tablename__ = 'promo_groups'
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -184,6 +184,13 @@ async def route_payment_by_method(
|
|||||||
await process_etoplatezhi_payment_amount(message, db_user, db, amount_kopeks, state)
|
await process_etoplatezhi_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||||
return True
|
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':
|
if payment_method == 'riopay':
|
||||||
from .riopay import process_riopay_payment_amount
|
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_sbp_topup, F.data == 'topup_etoplatezhi_sbp')
|
||||||
dp.callback_query.register(start_etoplatezhi_card_topup, F.data == 'topup_etoplatezhi_card')
|
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
|
from .mulenpay import check_mulenpay_payment_status
|
||||||
|
|
||||||
dp.callback_query.register(check_mulenpay_payment_status, F.data.startswith('check_mulenpay_'))
|
dp.callback_query.register(check_mulenpay_payment_status, F.data.startswith('check_mulenpay_'))
|
||||||
|
|||||||
@@ -1930,6 +1930,59 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
|||||||
)
|
)
|
||||||
has_direct_payment_methods = True
|
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():
|
if settings.is_support_topup_enabled():
|
||||||
keyboard.append(
|
keyboard.append(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -1518,6 +1518,7 @@ class BackupService:
|
|||||||
'overpay_payments',
|
'overpay_payments',
|
||||||
'aurapay_payments',
|
'aurapay_payments',
|
||||||
'etoplatezhi_payments',
|
'etoplatezhi_payments',
|
||||||
|
'antilopay_payments',
|
||||||
'apple_transactions',
|
'apple_transactions',
|
||||||
'saved_payment_methods',
|
'saved_payment_methods',
|
||||||
# --- Content/config ---
|
# --- Content/config ---
|
||||||
|
|||||||
@@ -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': 'СБП'},
|
{'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',
|
'overpay',
|
||||||
'aurapay',
|
'aurapay',
|
||||||
'etoplatezhi',
|
'etoplatezhi',
|
||||||
|
'antilopay',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from app.services.payment import (
|
|||||||
WataPaymentMixin,
|
WataPaymentMixin,
|
||||||
YooKassaPaymentMixin,
|
YooKassaPaymentMixin,
|
||||||
)
|
)
|
||||||
|
from app.services.payment.antilopay import AntilopayPaymentMixin
|
||||||
from app.services.payment.aurapay import AuraPayPaymentMixin
|
from app.services.payment.aurapay import AuraPayPaymentMixin
|
||||||
from app.services.payment.cloudpayments import CloudPaymentsPaymentMixin
|
from app.services.payment.cloudpayments import CloudPaymentsPaymentMixin
|
||||||
from app.services.payment.etoplatezhi import EtoplatezhiPaymentMixin
|
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)
|
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
|
# Mapping from model_name to getter function name for providers
|
||||||
# where it differs from the standard get_{model_name}_payment_by_id pattern.
|
# where it differs from the standard get_{model_name}_payment_by_id pattern.
|
||||||
_GETTER_OVERRIDES: dict[str, str] = {
|
_GETTER_OVERRIDES: dict[str, str] = {
|
||||||
@@ -546,6 +582,7 @@ class PaymentService(
|
|||||||
OverpayPaymentMixin,
|
OverpayPaymentMixin,
|
||||||
AuraPayPaymentMixin,
|
AuraPayPaymentMixin,
|
||||||
EtoplatezhiPaymentMixin,
|
EtoplatezhiPaymentMixin,
|
||||||
|
AntilopayPaymentMixin,
|
||||||
):
|
):
|
||||||
"""Основной интерфейс платежей, делегирующий работу специализированным mixin-ам."""
|
"""Основной интерфейс платежей, делегирующий работу специализированным mixin-ам."""
|
||||||
|
|
||||||
@@ -1075,6 +1112,28 @@ class PaymentService(
|
|||||||
}
|
}
|
||||||
return None
|
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 ---------------------------------------------------
|
# --- Telegram Stars ---------------------------------------------------
|
||||||
if payment_method == 'telegram_stars':
|
if payment_method == 'telegram_stars':
|
||||||
if not settings.TELEGRAM_STARS_ENABLED:
|
if not settings.TELEGRAM_STARS_ENABLED:
|
||||||
|
|||||||
@@ -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():
|
if settings.is_support_topup_enabled():
|
||||||
methods.append(
|
methods.append(
|
||||||
{
|
{
|
||||||
@@ -459,6 +512,14 @@ def is_payment_method_available(method_id: str) -> bool:
|
|||||||
return settings.is_etoplatezhi_sbp_enabled()
|
return settings.is_etoplatezhi_sbp_enabled()
|
||||||
if method_id == 'etoplatezhi_card':
|
if method_id == 'etoplatezhi_card':
|
||||||
return settings.is_etoplatezhi_card_enabled()
|
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':
|
if method_id == 'support':
|
||||||
return settings.is_support_topup_enabled()
|
return settings.is_support_topup_enabled()
|
||||||
return False
|
return False
|
||||||
@@ -492,6 +553,10 @@ def get_payment_method_status() -> dict[str, bool]:
|
|||||||
'etoplatezhi': settings.is_etoplatezhi_enabled(),
|
'etoplatezhi': settings.is_etoplatezhi_enabled(),
|
||||||
'etoplatezhi_sbp': settings.is_etoplatezhi_sbp_enabled(),
|
'etoplatezhi_sbp': settings.is_etoplatezhi_sbp_enabled(),
|
||||||
'etoplatezhi_card': settings.is_etoplatezhi_card_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(),
|
'support': settings.is_support_topup_enabled(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,4 +604,6 @@ def get_enabled_payment_methods_count() -> int:
|
|||||||
count += 1
|
count += 1
|
||||||
if settings.is_etoplatezhi_enabled():
|
if settings.is_etoplatezhi_enabled():
|
||||||
count += 1
|
count += 1
|
||||||
|
if settings.is_antilopay_enabled():
|
||||||
|
count += 1
|
||||||
return count
|
return count
|
||||||
|
|||||||
@@ -1516,6 +1516,54 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
|||||||
|
|
||||||
routes_registered = True
|
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:
|
if routes_registered:
|
||||||
|
|
||||||
@router.get('/health/payment-webhooks')
|
@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(),
|
'overpay_enabled': settings.is_overpay_enabled(),
|
||||||
'aurapay_enabled': settings.is_aurapay_enabled(),
|
'aurapay_enabled': settings.is_aurapay_enabled(),
|
||||||
'etoplatezhi_enabled': settings.is_etoplatezhi_enabled(),
|
'etoplatezhi_enabled': settings.is_etoplatezhi_enabled(),
|
||||||
|
'antilopay_enabled': settings.is_antilopay_enabled(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""create antilopay_payments table
|
||||||
|
|
||||||
|
Revision ID: 0070
|
||||||
|
Revises: 0069
|
||||||
|
Create Date: 2026-05-04
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = '0070'
|
||||||
|
down_revision: Union[str, None] = '0069'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'antilopay_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('antilopay_payment_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('antilopay_payments')
|
||||||
Reference in New Issue
Block a user