diff --git a/app/config.py b/app/config.py index 82cb061b..9cd6fa5b 100644 --- a/app/config.py +++ b/app/config.py @@ -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 diff --git a/app/database/crud/antilopay.py b/app/database/crud/antilopay.py new file mode 100644 index 00000000..37e6e689 --- /dev/null +++ b/app/database/crud/antilopay.py @@ -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 diff --git a/app/database/crud/transaction.py b/app/database/crud/transaction.py index ab30faa6..2b1a4821 100644 --- a/app/database/crud/transaction.py +++ b/app/database/crud/transaction.py @@ -32,6 +32,7 @@ REAL_PAYMENT_METHODS = [ PaymentMethod.OVERPAY.value, PaymentMethod.AURAPAY.value, PaymentMethod.ETOPLATEZHI.value, + PaymentMethod.ANTILOPAY.value, ] diff --git a/app/database/models.py b/app/database/models.py index fd2bc305..fa6fc78b 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -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'' +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'' + + class PromoGroup(Base): __tablename__ = 'promo_groups' diff --git a/app/handlers/balance/antilopay.py b/app/handlers/balance/antilopay.py new file mode 100644 index 00000000..1e699e6b --- /dev/null +++ b/app/handlers/balance/antilopay.py @@ -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 Оплата через {name}\n\n' + 'Сумма: {amount}\u20bd\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 Пополнение ограничено\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 Пополнение ограничено\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 Пополнение через {name}\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') diff --git a/app/handlers/balance/main.py b/app/handlers/balance/main.py index 0242d23b..2047371d 100644 --- a/app/handlers/balance/main.py +++ b/app/handlers/balance/main.py @@ -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_')) diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 6d5f471e..98986af7 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -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( [ diff --git a/app/services/antilopay_service.py b/app/services/antilopay_service.py new file mode 100644 index 00000000..153a2b99 --- /dev/null +++ b/app/services/antilopay_service.py @@ -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() diff --git a/app/services/backup_service.py b/app/services/backup_service.py index ddff2db4..28ec6bcf 100644 --- a/app/services/backup_service.py +++ b/app/services/backup_service.py @@ -1518,6 +1518,7 @@ class BackupService: 'overpay_payments', 'aurapay_payments', 'etoplatezhi_payments', + 'antilopay_payments', 'apple_transactions', 'saved_payment_methods', # --- Content/config --- diff --git a/app/services/payment/antilopay.py b/app/services/payment/antilopay.py new file mode 100644 index 00000000..a76385b4 --- /dev/null +++ b/app/services/payment/antilopay.py @@ -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 Пополнение успешно!\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 diff --git a/app/services/payment_method_config_service.py b/app/services/payment_method_config_service.py index 9e213284..ab583bd4 100644 --- a/app/services/payment_method_config_service.py +++ b/app/services/payment_method_config_service.py @@ -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', ] diff --git a/app/services/payment_service.py b/app/services/payment_service.py index 11f4c556..55955910 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -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: diff --git a/app/utils/payment_utils.py b/app/utils/payment_utils.py index 03d1e128..973fe118 100644 --- a/app/utils/payment_utils.py +++ b/app/utils/payment_utils.py @@ -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 diff --git a/app/webserver/payments.py b/app/webserver/payments.py index c0aa4651..4112a0e8 100644 --- a/app/webserver/payments.py +++ b/app/webserver/payments.py @@ -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(), } ) diff --git a/migrations/alembic/versions/0070_create_antilopay_payments.py b/migrations/alembic/versions/0070_create_antilopay_payments.py new file mode 100644 index 00000000..6b690203 --- /dev/null +++ b/migrations/alembic/versions/0070_create_antilopay_payments.py @@ -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')