diff --git a/app/cabinet/routes/balance.py b/app/cabinet/routes/balance.py index cacbe581..130c2d50 100644 --- a/app/cabinet/routes/balance.py +++ b/app/cabinet/routes/balance.py @@ -854,6 +854,35 @@ async def create_topup( detail='Failed to create RollyPay payment', ) + elif request.payment_method == 'overpay': + if not settings.is_overpay_enabled(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Overpay payment method is unavailable', + ) + + payment_service = PaymentService() + result = await payment_service.create_overpay_payment( + db=db, + user_id=user.id, + amount_kopeks=request.amount_kopeks, + description=settings.get_balance_payment_description( + request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id + ), + email=getattr(user, 'email', None), + language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE, + return_url=cabinet_success_url, + ) + + if result and result.get('payment_url'): + payment_url = result.get('payment_url') + payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending') + else: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail='Failed to create Overpay payment', + ) + elif request.payment_method == 'aurapay': if not settings.is_aurapay_enabled(): raise HTTPException( diff --git a/app/config.py b/app/config.py index 00ca0184..2281e5ef 100644 --- a/app/config.py +++ b/app/config.py @@ -629,6 +629,23 @@ class Settings(BaseSettings): ROLLYPAY_WEBHOOK_PATH: str = '/rollypay-webhook' ROLLYPAY_RETURN_URL: str | None = None + # Overpay (pay.overpay.io) + OVERPAY_ENABLED: bool = False + OVERPAY_API_URL: str = 'https://api.overpay.io' + OVERPAY_USERNAME: str | None = None + OVERPAY_PASSWORD: str | None = None + OVERPAY_PROJECT_ID: str | None = None + OVERPAY_P12_PATH: str | None = None + OVERPAY_P12_PASSPHRASE: str | None = None + OVERPAY_DISPLAY_NAME: str = 'Overpay' + OVERPAY_CURRENCY: str = 'RUB' + OVERPAY_MIN_AMOUNT_KOPEKS: int = 10000 + OVERPAY_MAX_AMOUNT_KOPEKS: int = 10000000 + OVERPAY_WEBHOOK_PATH: str = '/overpay-webhook' + OVERPAY_RETURN_URL: str | None = None + OVERPAY_LIFETIME_MINUTES: int = 1440 + OVERPAY_PAYMENT_METHODS: str = 'card,fps' + # AuraPay (aurapay.tech) AURAPAY_ENABLED: bool = False AURAPAY_API_KEY: str | None = None # X-ApiKey header @@ -2054,6 +2071,21 @@ class Settings(BaseSettings): def get_rollypay_display_name_html(self) -> str: return html.escape(self.get_rollypay_display_name()) + def is_overpay_enabled(self) -> bool: + return ( + self.OVERPAY_ENABLED + and self.OVERPAY_USERNAME is not None + and self.OVERPAY_PASSWORD is not None + and self.OVERPAY_PROJECT_ID is not None + ) + + def get_overpay_display_name(self) -> str: + name = (self.OVERPAY_DISPLAY_NAME or '').strip() + return name if name else 'Overpay' + + def get_overpay_display_name_html(self) -> str: + return html.escape(self.get_overpay_display_name()) + def is_aurapay_enabled(self) -> bool: return ( self.AURAPAY_ENABLED diff --git a/app/database/crud/overpay.py b/app/database/crud/overpay.py new file mode 100644 index 00000000..594d5b5a --- /dev/null +++ b/app/database/crud/overpay.py @@ -0,0 +1,157 @@ +"""CRUD операции для платежей Overpay.""" + +from datetime import UTC, datetime + +import structlog +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import OverpayPayment + + +logger = structlog.get_logger(__name__) + + +async def create_overpay_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, + overpay_payment_id: str | None = None, + expires_at: datetime | None = None, + metadata_json: dict | None = None, +) -> OverpayPayment: + """Создает запись о платеже Overpay.""" + payment = OverpayPayment( + user_id=user_id, + order_id=order_id, + amount_kopeks=amount_kopeks, + currency=currency, + description=description, + payment_url=payment_url, + payment_method=payment_method, + overpay_payment_id=overpay_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('Создан платеж Overpay', order_id=order_id, user_id=user_id) + return payment + + +async def get_overpay_payment_by_order_id(db: AsyncSession, order_id: str) -> OverpayPayment | None: + """Получает платеж по order_id (internal).""" + result = await db.execute(select(OverpayPayment).where(OverpayPayment.order_id == order_id)) + return result.scalar_one_or_none() + + +async def get_overpay_payment_by_overpay_id(db: AsyncSession, overpay_payment_id: str) -> OverpayPayment | None: + """Получает платеж по ID от Overpay.""" + result = await db.execute(select(OverpayPayment).where(OverpayPayment.overpay_payment_id == overpay_payment_id)) + return result.scalar_one_or_none() + + +async def get_overpay_payment_by_id(db: AsyncSession, payment_id: int) -> OverpayPayment | None: + """Получает платеж по ID.""" + result = await db.execute(select(OverpayPayment).where(OverpayPayment.id == payment_id)) + return result.scalar_one_or_none() + + +async def get_overpay_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> OverpayPayment | None: + """Получает платеж по ID с блокировкой FOR UPDATE.""" + result = await db.execute( + select(OverpayPayment) + .where(OverpayPayment.id == payment_id) + .with_for_update() + .execution_options(populate_existing=True) + ) + return result.scalar_one_or_none() + + +async def update_overpay_payment_status( + db: AsyncSession, + payment: OverpayPayment, + *, + status: str, + is_paid: bool | None = None, + overpay_payment_id: str | None = None, + payment_method: str | None = None, + callback_payload: dict | None = None, + transaction_id: int | None = None, +) -> OverpayPayment: + """Обновляет статус платежа.""" + 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 overpay_payment_id is not None: + payment.overpay_payment_id = overpay_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( + 'Обновлен статус платежа Overpay', + order_id=payment.order_id, + status=status, + is_paid=payment.is_paid, + ) + return payment + + +async def get_pending_overpay_payments(db: AsyncSession, user_id: int) -> list[OverpayPayment]: + """Получает незавершенные платежи пользователя.""" + result = await db.execute( + select(OverpayPayment).where( + OverpayPayment.user_id == user_id, + OverpayPayment.status == 'pending', + OverpayPayment.is_paid == False, + ) + ) + return list(result.scalars().all()) + + +async def get_expired_pending_overpay_payments( + db: AsyncSession, +) -> list[OverpayPayment]: + """Получает просроченные платежи в статусе pending.""" + now = datetime.now(UTC) + result = await db.execute( + select(OverpayPayment).where( + OverpayPayment.status == 'pending', + OverpayPayment.is_paid == False, + OverpayPayment.expires_at < now, + ) + ) + return list(result.scalars().all()) + + +async def link_overpay_payment_to_transaction( + db: AsyncSession, + *, + payment: OverpayPayment, + transaction_id: int, +) -> OverpayPayment: + """Связывает платеж с транзакцией.""" + 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/models.py b/app/database/models.py index eec6efed..87648a74 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -164,6 +164,7 @@ class PaymentMethod(Enum): SEVERPAY = 'severpay' PAYPEAR = 'paypear' ROLLYPAY = 'rollypay' + OVERPAY = 'overpay' AURAPAY = 'aurapay' MANUAL = 'manual' BALANCE = 'balance' @@ -1005,6 +1006,68 @@ class RollyPayPayment(Base): return f'' +class OverpayPayment(Base): + """Платежи через Overpay (pay.overpay.io).""" + + __tablename__ = 'overpay_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 + overpay_payment_id = Column(String(128), unique=True, nullable=True, index=True) # ID от Overpay + + # Суммы + 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='overpay_payments') + transaction = relationship('Transaction', backref='overpay_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', 'chargeback', 'amount_mismatch'] + + def __repr__(self) -> str: # pragma: no cover - debug helper + return f'' + + class AuraPayPayment(Base): """Платежи через AuraPay (aurapay.tech).""" diff --git a/app/handlers/admin/bot_configuration.py b/app/handlers/admin/bot_configuration.py index 9920c9b0..6ca53208 100644 --- a/app/handlers/admin/bot_configuration.py +++ b/app/handlers/admin/bot_configuration.py @@ -78,6 +78,7 @@ CATEGORY_GROUP_METADATA: dict[str, dict[str, object]] = { 'SEVERPAY', 'PAYPEAR', 'ROLLYPAY', + 'OVERPAY', 'AURAPAY', 'MULENPAY', 'PAL24', @@ -1269,6 +1270,9 @@ def _build_settings_keyboard( elif category_key == 'ROLLYPAY': label = texts.t('PAYMENT_ROLLYPAY', f'💳 {settings.get_rollypay_display_name()}') test_payment_buttons.append([_test_button(f'{label} · тест', 'rollypay')]) + elif category_key == 'OVERPAY': + label = texts.t('PAYMENT_OVERPAY', f'💳 {settings.get_overpay_display_name()}') + test_payment_buttons.append([_test_button(f'{label} · тест', 'overpay')]) elif category_key == 'AURAPAY': label = texts.t('PAYMENT_AURAPAY', f'💳 {settings.get_aurapay_display_name()}') test_payment_buttons.append([_test_button(f'{label} · тест', 'aurapay')]) diff --git a/app/handlers/balance/main.py b/app/handlers/balance/main.py index 12d5bc22..98a365c6 100644 --- a/app/handlers/balance/main.py +++ b/app/handlers/balance/main.py @@ -163,6 +163,13 @@ async def route_payment_by_method( await process_rollypay_payment_amount(message, db_user, db, amount_kopeks, state) return True + if payment_method == 'overpay': + from .overpay import process_overpay_payment_amount + + async with AsyncSessionLocal() as db: + await process_overpay_payment_amount(message, db_user, db, amount_kopeks, state) + return True + if payment_method == 'aurapay': from .aurapay import process_aurapay_payment_amount @@ -757,6 +764,10 @@ def register_balance_handlers(dp: Dispatcher): dp.callback_query.register(start_rollypay_topup, F.data == 'topup_rollypay') + from .overpay import start_overpay_topup + + dp.callback_query.register(start_overpay_topup, F.data == 'topup_overpay') + from .aurapay import start_aurapay_topup dp.callback_query.register(start_aurapay_topup, F.data == 'topup_aurapay') diff --git a/app/handlers/balance/overpay.py b/app/handlers/balance/overpay.py new file mode 100644 index 00000000..f1644a2c --- /dev/null +++ b/app/handlers/balance/overpay.py @@ -0,0 +1,247 @@ +"""Handler for Overpay 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_overpay_payment_and_respond( + message_or_callback, + db_user: User, + db: AsyncSession, + amount_kopeks: int, + edit_message: bool = False, +): + """ + Common logic for creating Overpay 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_overpay_payment( + db=db, + user_id=db_user.id, + amount_kopeks=amount_kopeks, + description=description, + email=getattr(db_user, 'email', None), + language=db_user.language, + ) + + 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_overpay_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( + 'OVERPAY_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('Overpay payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub) + + +@error_handler +async def process_overpay_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.OVERPAY_MIN_AMOUNT_KOPEKS + max_amount = settings.OVERPAY_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 + + await state.clear() + + await _create_overpay_payment_and_respond( + message_or_callback=message, + db_user=db_user, + db=db, + amount_kopeks=amount_kopeks, + edit_message=False, + ) + + +@error_handler +async def start_overpay_topup( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, +): + """ + Start Overpay top-up process - ask for amount. + """ + 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='overpay') + + min_amount = settings.OVERPAY_MIN_AMOUNT_KOPEKS // 100 + max_amount = settings.OVERPAY_MAX_AMOUNT_KOPEKS // 100 + display_name = settings.get_overpay_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( + 'OVERPAY_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, + ) diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 0fe51cdd..b7934210 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -1831,6 +1831,18 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN ) has_direct_payment_methods = True + if settings.is_overpay_enabled(): + overpay_name = settings.get_overpay_display_name() + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('PAYMENT_OVERPAY', f'💳 {overpay_name}'), + callback_data=_build_callback('overpay'), + ) + ] + ) + has_direct_payment_methods = True + if settings.is_aurapay_enabled(): aurapay_name = settings.get_aurapay_display_name() keyboard.append( diff --git a/app/services/overpay_service.py b/app/services/overpay_service.py new file mode 100644 index 00000000..7373eced --- /dev/null +++ b/app/services/overpay_service.py @@ -0,0 +1,291 @@ +"""Сервис для работы с API Overpay (pay.overpay.io).""" + +import ssl +import tempfile +from typing import Any + +import httpx +import structlog +from cryptography.hazmat.primitives.serialization import ( + BestAvailableEncryption, + Encoding, + NoEncryption, + PrivateFormat, + pkcs12, +) + +from app.config import settings + + +logger = structlog.get_logger(__name__) + + +class OverpayAPIError(Exception): + """Ошибка API Overpay.""" + + def __init__(self, status_code: int, message: str): + self.status_code = status_code + self.message = message + super().__init__(f'Overpay API error ({status_code}): {message}') + + +class OverpayService: + """Сервис для работы с API Overpay. + + Overpay использует HTTP Basic Auth + mTLS (P12 сертификат). + """ + + def __init__(self): + self._client: httpx.AsyncClient | None = None + self._ssl_context: ssl.SSLContext | None = None + self._temp_cert_file: str | None = None + self._temp_key_file: str | None = None + + @property + def api_url(self) -> str: + return (settings.OVERPAY_API_URL or 'https://api.overpay.io').rstrip('/') + + @property + def username(self) -> str: + return settings.OVERPAY_USERNAME or '' + + @property + def password(self) -> str: + return settings.OVERPAY_PASSWORD or '' + + @property + def project_id(self) -> str: + return settings.OVERPAY_PROJECT_ID or '' + + def _build_ssl_context(self) -> ssl.SSLContext | None: + """Создает SSL контекст с P12 сертификатом для mTLS.""" + p12_path = settings.OVERPAY_P12_PATH + if not p12_path: + return None + + if self._ssl_context is not None: + return self._ssl_context + + try: + passphrase = settings.OVERPAY_P12_PASSPHRASE + passphrase_bytes = passphrase.encode('utf-8') if passphrase else None + + with open(p12_path, 'rb') as f: + p12_data = f.read() + + private_key, certificate, additional_certs = pkcs12.load_key_and_certificates(p12_data, passphrase_bytes) + + # Write PEM files to temp files for ssl.SSLContext + cert_pem = certificate.public_bytes(Encoding.PEM) + if additional_certs: + for cert in additional_certs: + cert_pem += cert.public_bytes(Encoding.PEM) + + if passphrase_bytes: + key_pem = private_key.private_bytes( + Encoding.PEM, + PrivateFormat.TraditionalOpenSSL, + BestAvailableEncryption(passphrase_bytes), + ) + else: + key_pem = private_key.private_bytes( + Encoding.PEM, + PrivateFormat.TraditionalOpenSSL, + NoEncryption(), + ) + + # Write to temp files + with tempfile.NamedTemporaryFile(delete=False, suffix='.pem') as cert_file: + cert_file.write(cert_pem) + cert_file.flush() + self._temp_cert_file = cert_file.name + + with tempfile.NamedTemporaryFile(delete=False, suffix='.pem') as key_file: + key_file.write(key_pem) + key_file.flush() + self._temp_key_file = key_file.name + + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.load_cert_chain( + certfile=self._temp_cert_file, + keyfile=self._temp_key_file, + password=passphrase, + ) + ctx.load_default_certs() + self._ssl_context = ctx + logger.info('Overpay: SSL контекст с P12 сертификатом создан') + return ctx + + except Exception as e: + logger.exception('Overpay: ошибка загрузки P12 сертификата', error=e) + return None + + async def _get_client(self) -> httpx.AsyncClient: + """Возвращает переиспользуемый HTTP-клиент с mTLS.""" + if self._client is not None and not self._client.is_closed: + return self._client + + ssl_context = self._build_ssl_context() + + self._client = httpx.AsyncClient( + timeout=httpx.Timeout(30.0), + auth=httpx.BasicAuth(self.username, self.password), + verify=ssl_context if ssl_context else True, + ) + return self._client + + async def close(self) -> None: + """Закрывает HTTP-клиент.""" + if self._client and not self._client.is_closed: + await self._client.aclose() + self._client = None + + # Clean up temp files + from pathlib import Path + + for path in (self._temp_cert_file, self._temp_key_file): + if path: + try: + Path(path).unlink() + except OSError: + pass + self._temp_cert_file = None + self._temp_key_file = None + self._ssl_context = None + + async def create_payment( + self, + *, + amount: str, + currency: str = 'RUB', + lifetime_minutes: int = 1440, + merchant_transaction_id: str, + description: str = '', + return_url: str | None = None, + payment_methods: list[str] | None = None, + ) -> dict[str, Any]: + """ + Создает платеж через API Overpay. + POST {API_URL}/orders/ + """ + payload: dict[str, Any] = { + 'amount': amount, + 'currency': currency, + 'livetimeMinutes': lifetime_minutes, + 'projectId': self.project_id, + 'merchantTransactionId': merchant_transaction_id, + } + + if description: + payload['description'] = description + if return_url: + payload['returnUrl'] = return_url + if payment_methods: + payload['paymentMethods'] = payment_methods + + logger.info( + 'Overpay API create_payment', + merchant_transaction_id=merchant_transaction_id, + amount=amount, + currency=currency, + ) + + try: + client = await self._get_client() + response = await client.post( + f'{self.api_url}/orders/', + json=payload, + headers={'Content-Type': 'application/json'}, + ) + + data = response.json() + + if response.status_code == 200 or response.status_code == 201: + logger.info( + 'Overpay API payment created', + merchant_transaction_id=merchant_transaction_id, + overpay_id=data.get('id'), + result_url=data.get('resultUrl'), + ) + return data + + error_msg = data.get('message') or data.get('error') or str(data) + logger.error( + 'Overpay create_payment error', + status_code=response.status_code, + error_msg=error_msg, + response_data=data, + ) + raise OverpayAPIError(response.status_code, error_msg) + + except httpx.HTTPError as e: + logger.exception('Overpay API connection error', error=e) + raise + + async def get_payment(self, order_id: str) -> dict[str, Any]: + """ + Получает информацию о платеже по ID. + GET {API_URL}/orders/{id} + """ + logger.info('Overpay get_payment', order_id=order_id) + + try: + client = await self._get_client() + response = await client.get( + f'{self.api_url}/orders/{order_id}', + headers={'Content-Type': 'application/json'}, + ) + + data = response.json() + + if response.status_code == 200: + return data + + error_msg = data.get('message') or data.get('error') or str(data) + logger.error( + 'Overpay get_payment error', + status_code=response.status_code, + error_msg=error_msg, + ) + raise OverpayAPIError(response.status_code, error_msg) + + except httpx.HTTPError as e: + logger.exception('Overpay API connection error', error=e) + raise + + async def refund_payment(self, order_id: str, amount: str) -> dict[str, Any]: + """ + Возврат платежа. + PUT {API_URL}/orders/{id}/refund + """ + logger.info('Overpay refund_payment', order_id=order_id, amount=amount) + + try: + client = await self._get_client() + response = await client.put( + f'{self.api_url}/orders/{order_id}/refund', + json={'amount': amount}, + headers={'Content-Type': 'application/json'}, + ) + + data = response.json() + + if response.status_code == 200: + logger.info('Overpay refund successful', order_id=order_id, amount=amount) + return data + + error_msg = data.get('message') or data.get('error') or str(data) + logger.error( + 'Overpay refund error', + status_code=response.status_code, + error_msg=error_msg, + ) + raise OverpayAPIError(response.status_code, error_msg) + + except httpx.HTTPError as e: + logger.exception('Overpay API connection error', error=e) + raise + + +# Singleton instance +overpay_service = OverpayService() diff --git a/app/services/payment/__init__.py b/app/services/payment/__init__.py index 6f137845..dc91b630 100644 --- a/app/services/payment/__init__.py +++ b/app/services/payment/__init__.py @@ -12,6 +12,7 @@ from .freekassa import FreekassaPaymentMixin from .heleket import HeleketPaymentMixin from .kassa_ai import KassaAiPaymentMixin from .mulenpay import MulenPayPaymentMixin +from .overpay import OverpayPaymentMixin from .pal24 import Pal24PaymentMixin from .paypear import PayPearPaymentMixin from .platega import PlategaPaymentMixin @@ -32,6 +33,7 @@ __all__ = [ 'HeleketPaymentMixin', 'KassaAiPaymentMixin', 'MulenPayPaymentMixin', + 'OverpayPaymentMixin', 'Pal24PaymentMixin', 'PayPearPaymentMixin', 'PaymentCommonMixin', diff --git a/app/services/payment/overpay.py b/app/services/payment/overpay.py new file mode 100644 index 00000000..f5d1f16e --- /dev/null +++ b/app/services/payment/overpay.py @@ -0,0 +1,567 @@ +"""Mixin для интеграции с Overpay (pay.overpay.io).""" + +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.overpay_service import overpay_service +from app.utils.payment_logger import payment_logger as logger +from app.utils.user_utils import format_referrer_info + + +# Маппинг статусов Overpay -> internal +OVERPAY_STATUS_MAP: dict[str, tuple[str, bool]] = { + 'charged': ('success', True), + 'authorized': ('authorized', False), + 'preflight': ('pending', False), + 'new': ('pending', False), + 'processing': ('processing', False), + 'prepared': ('processing', False), + 'rejected': ('rejected', False), + 'declined': ('declined', False), + 'reversed': ('reversed', False), + 'refunded': ('refunded', False), + 'chargeback': ('chargeback', False), + 'error': ('error', False), +} + + +class OverpayPaymentMixin: + """Mixin для работы с платежами Overpay.""" + + async def create_overpay_payment( + self, + db: AsyncSession, + *, + user_id: int | None, + amount_kopeks: int, + description: str = 'Пополнение баланса', + email: str | None = None, + language: str = 'ru', + return_url: str | None = None, + ) -> dict[str, Any] | None: + """ + Создает платеж Overpay. + + Returns: + Словарь с данными платежа или None при ошибке + """ + if not settings.is_overpay_enabled(): + logger.error('Overpay не настроен') + return None + + # Валидация лимитов + if amount_kopeks < settings.OVERPAY_MIN_AMOUNT_KOPEKS: + logger.warning( + 'Overpay: сумма меньше минимальной', + amount_kopeks=amount_kopeks, + OVERPAY_MIN_AMOUNT_KOPEKS=settings.OVERPAY_MIN_AMOUNT_KOPEKS, + ) + return None + + if amount_kopeks > settings.OVERPAY_MAX_AMOUNT_KOPEKS: + logger.warning( + 'Overpay: сумма больше максимальной', + amount_kopeks=amount_kopeks, + OVERPAY_MAX_AMOUNT_KOPEKS=settings.OVERPAY_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'op{tg_id}_{uuid.uuid4().hex[:6]}' + amount_rubles = amount_kopeks / 100 + amount_value = f'{amount_rubles:.2f}' + currency = settings.OVERPAY_CURRENCY + + # Метаданные + metadata = { + 'user_id': user_id, + 'amount_kopeks': amount_kopeks, + 'description': description, + 'language': language, + 'type': 'balance_topup', + } + + # Методы оплаты из настроек + payment_methods_str = settings.OVERPAY_PAYMENT_METHODS + payment_methods = ( + [m.strip() for m in payment_methods_str.split(',') if m.strip()] if payment_methods_str else None + ) + + try: + # Используем API для создания платежа + result = await overpay_service.create_payment( + amount=amount_value, + currency=currency, + lifetime_minutes=settings.OVERPAY_LIFETIME_MINUTES, + merchant_transaction_id=order_id, + description=description, + return_url=return_url or settings.OVERPAY_RETURN_URL, + payment_methods=payment_methods, + ) + + payment_url = result.get('resultUrl') + overpay_payment_id = str(result.get('id', '')) if result.get('id') else None + + if not payment_url: + logger.error('Overpay API не вернул URL платежа', result=result) + return None + + logger.info( + 'Overpay API: создан платеж', + order_id=order_id, + overpay_payment_id=overpay_payment_id, + payment_url=payment_url, + ) + + # Срок действия + expires_at = datetime.now(UTC) + timedelta(minutes=settings.OVERPAY_LIFETIME_MINUTES) + + # Сохраняем в БД + overpay_crud = import_module('app.database.crud.overpay') + local_payment = await overpay_crud.create_overpay_payment( + db=db, + user_id=user_id, + order_id=order_id, + amount_kopeks=amount_kopeks, + currency=currency, + description=description, + payment_url=payment_url, + overpay_payment_id=overpay_payment_id, + expires_at=expires_at, + metadata_json=metadata, + ) + + logger.info( + 'Overpay: создан платеж', + order_id=order_id, + user_id=user_id, + amount_rubles=amount_rubles, + currency=currency, + ) + + return { + 'order_id': order_id, + 'overpay_payment_id': overpay_payment_id, + 'amount_kopeks': amount_kopeks, + 'amount_rubles': amount_rubles, + 'currency': currency, + 'payment_url': payment_url, + 'expires_at': expires_at.isoformat(), + 'local_payment_id': local_payment.id, + } + + except Exception as e: + logger.exception('Overpay: ошибка создания платежа', error=e) + return None + + async def process_overpay_webhook( + self, + db: AsyncSession, + payload: dict[str, Any], + ) -> bool: + """ + Обрабатывает webhook от Overpay. + + mTLS обеспечивает аутентификацию; дополнительно проверяем наличие платежа в БД. + + Args: + db: Сессия БД + payload: JSON тело webhook + + Returns: + True если платеж успешно обработан + """ + try: + overpay_payment_id = str(payload.get('id', '')) if payload.get('id') else None + merchant_transaction_id = payload.get('merchantTransactionId') + overpay_status = payload.get('status') + + if not overpay_payment_id or not overpay_status: + logger.warning('Overpay webhook: отсутствуют обязательные поля', payload=payload) + return False + + # Ищем платеж по order_id (наш merchantTransactionId) или overpay_payment_id + overpay_crud = import_module('app.database.crud.overpay') + payment = None + if merchant_transaction_id: + payment = await overpay_crud.get_overpay_payment_by_order_id(db, merchant_transaction_id) + if not payment and overpay_payment_id: + payment = await overpay_crud.get_overpay_payment_by_overpay_id(db, overpay_payment_id) + + if not payment: + logger.warning( + 'Overpay webhook: платеж не найден', + merchant_transaction_id=merchant_transaction_id, + overpay_payment_id=overpay_payment_id, + ) + return False + + # Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race) + locked = await overpay_crud.get_overpay_payment_by_id_for_update(db, payment.id) + if not locked: + logger.error('Overpay: не удалось заблокировать платёж', payment_id=payment.id) + return False + payment = locked + + # Проверка дублирования (re-check from locked row) + if payment.is_paid: + logger.info('Overpay webhook: платеж уже обработан', order_id=payment.order_id) + return True + + # Маппинг статуса + status_info = OVERPAY_STATUS_MAP.get(overpay_status, ('pending', False)) + internal_status, is_paid = status_info + + callback_payload = { + 'overpay_payment_id': overpay_payment_id, + 'merchant_transaction_id': merchant_transaction_id, + 'status': overpay_status, + } + + # Финализируем платеж если оплачен — без промежуточного 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.overpay_payment_id = overpay_payment_id or payment.overpay_payment_id + payment.callback_payload = callback_payload + payment.updated_at = datetime.now(UTC) + await db.flush() + return await self._finalize_overpay_payment( + db, payment, overpay_payment_id=overpay_payment_id, trigger='webhook' + ) + + # Для не-success статусов можно безопасно коммитить + payment = await overpay_crud.update_overpay_payment_status( + db=db, + payment=payment, + status=internal_status, + is_paid=False, + overpay_payment_id=overpay_payment_id, + callback_payload=callback_payload, + ) + + return True + + except Exception as e: + logger.exception('Overpay webhook: ошибка обработки', error=e) + return False + + async def _finalize_overpay_payment( + self, + db: AsyncSession, + payment: Any, + *, + overpay_payment_id: str | None, + trigger: str, + ) -> bool: + """Создаёт транзакцию, начисляет баланс и отправляет уведомления. + + FOR UPDATE lock must be acquired by the caller before invoking this method. + """ + payment_module = import_module('app.services.payment_service') + overpay_crud = import_module('app.database.crud.overpay') + + # FOR UPDATE lock already acquired by caller — just check idempotency + if payment.transaction_id: + logger.info( + 'Overpay платеж уже связан с транзакцией', + 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=str(overpay_payment_id) if overpay_payment_id else payment.order_id, + provider_name='overpay', + ) + 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('Пользователь не найден для Overpay', 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 = str(overpay_payment_id) if overpay_payment_id else 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.OVERPAY, + ) + + display_name = settings.get_overpay_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.OVERPAY, + external_id=transaction_external_id, + is_completed=True, + created_at=getattr(payment, 'created_at', None), + commit=False, + ) + created_transaction = True + + await overpay_crud.link_overpay_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('Overpay платеж уже зачислил баланс ранее', 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.OVERPAY, + 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('Ошибка обработки реферального пополнения Overpay', 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('Ошибка отправки админ уведомления Overpay', 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('Ошибка отправки уведомления пользователю Overpay', 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( + 'Обработан Overpay платеж', + order_id=payment.order_id, + user_id=payment.user_id, + trigger=trigger, + ) + + return True + + async def check_overpay_payment_status( + self, + db: AsyncSession, + order_id: str, + ) -> dict[str, Any] | None: + """Проверяет статус платежа через API.""" + try: + overpay_crud = import_module('app.database.crud.overpay') + payment = await overpay_crud.get_overpay_payment_by_order_id(db, order_id) + if not payment: + logger.warning('Overpay payment not found', order_id=order_id) + return None + + if payment.is_paid: + return { + 'payment': payment, + 'status': 'success', + 'is_paid': True, + } + + # Проверяем через API по overpay_payment_id + if payment.overpay_payment_id: + try: + order_data = await overpay_service.get_payment(payment.overpay_payment_id) + overpay_status = order_data.get('status') + + if overpay_status: + status_info = OVERPAY_STATUS_MAP.get(overpay_status, ('pending', False)) + internal_status, is_paid = status_info + + if is_paid: + # Acquire FOR UPDATE lock before finalization + locked = await overpay_crud.get_overpay_payment_by_id_for_update(db, payment.id) + if not locked: + logger.error('Overpay: не удалось заблокировать платёж', payment_id=payment.id) + return None + payment = locked + + if payment.is_paid: + logger.info('Overpay платеж уже обработан (api_check)', order_id=payment.order_id) + return { + 'payment': payment, + 'status': 'success', + 'is_paid': True, + } + + logger.info('Overpay payment confirmed via API', order_id=payment.order_id) + + # Inline field updates — NO intermediate commit that would release FOR UPDATE lock + payment.status = 'success' + payment.is_paid = True + payment.paid_at = datetime.now(UTC) + payment.callback_payload = { + 'check_source': 'api', + 'overpay_order_data': order_data, + } + payment.updated_at = datetime.now(UTC) + await db.flush() + + await self._finalize_overpay_payment( + db, + payment, + overpay_payment_id=payment.overpay_payment_id, + trigger='api_check', + ) + elif internal_status != payment.status: + # Обновляем статус если изменился + payment = await overpay_crud.update_overpay_payment_status( + db=db, + payment=payment, + status=internal_status, + ) + + except Exception as e: + logger.error('Error checking Overpay payment status via API', error=e) + + return { + 'payment': payment, + 'status': payment.status or 'pending', + 'is_paid': payment.is_paid, + } + + except Exception as e: + logger.exception('Overpay: ошибка проверки статуса', error=e) + return None diff --git a/app/services/payment_method_config_service.py b/app/services/payment_method_config_service.py index 25d71dd1..35f21da6 100644 --- a/app/services/payment_method_config_service.py +++ b/app/services/payment_method_config_service.py @@ -169,6 +169,16 @@ def _get_method_defaults() -> dict: {'id': 'crypto', 'name': 'Криптовалюта'}, ], }, + 'overpay': { + 'default_display_name': settings.get_overpay_display_name(), + 'is_configured': settings.is_overpay_enabled(), + 'default_min': settings.OVERPAY_MIN_AMOUNT_KOPEKS, + 'default_max': settings.OVERPAY_MAX_AMOUNT_KOPEKS, + 'available_sub_options': [ + {'id': 'card', 'name': 'Карта'}, + {'id': 'fps', 'name': 'СБП'}, + ], + }, 'aurapay': { 'default_display_name': settings.get_aurapay_display_name(), 'is_configured': settings.is_aurapay_enabled(), @@ -223,6 +233,7 @@ DEFAULT_METHOD_ORDER = [ 'severpay', 'paypear', 'rollypay', + 'overpay', 'aurapay', ] diff --git a/app/services/payment_search_service.py b/app/services/payment_search_service.py index 18d31943..b8418365 100644 --- a/app/services/payment_search_service.py +++ b/app/services/payment_search_service.py @@ -21,6 +21,7 @@ from app.database.models import ( HeleketPayment, KassaAiPayment, MulenPayPayment, + OverpayPayment, Pal24Payment, PaymentMethod, PlategaPayment, @@ -649,6 +650,39 @@ async def _search_severpay(db: AsyncSession, params: SearchParams) -> list[Pendi return records +async def _search_overpay(db: AsyncSession, params: SearchParams) -> list[PendingPayment]: + stmt = select(OverpayPayment).options(selectinload(OverpayPayment.user)).order_by(desc(OverpayPayment.created_at)) + stmt = _apply_date_filter(stmt, OverpayPayment.created_at, params.cutoff, params.upper_bound) + + if params.search: + kind = _detect_user_search_kind(params.search) + if kind == _UserSearchKind.INVOICE: + conditions = [ + OverpayPayment.order_id.ilike(f'%{_escape_like(params.search)}%'), + OverpayPayment.overpay_payment_id.ilike(f'%{_escape_like(params.search)}%'), + ] + stmt = stmt.where(or_(*conditions)) + else: + stmt = _apply_user_join_filter(stmt, OverpayPayment, kind, params.search) + + stmt = stmt.limit(MAX_RECORDS_PER_PROVIDER) + result = await db.execute(stmt) + records: list[PendingPayment] = [] + for payment in result.scalars().all(): + record = _build_record( + PaymentMethod.OVERPAY, + payment, + identifier=payment.order_id, + amount_kopeks=payment.amount_kopeks, + status=payment.status or '', + is_paid=bool(payment.is_paid), + expires_at=getattr(payment, 'expires_at', None), + ) + if record: + records.append(record) + return records + + async def _search_stars(db: AsyncSession, params: SearchParams) -> list[PendingPayment]: stmt = ( select(Transaction) @@ -702,6 +736,7 @@ _PROVIDER_SEARCH_MAP: dict[PaymentMethod, Any] = { PaymentMethod.KASSA_AI: _search_kassa_ai, PaymentMethod.RIOPAY: _search_riopay, PaymentMethod.SEVERPAY: _search_severpay, + PaymentMethod.OVERPAY: _search_overpay, PaymentMethod.TELEGRAM_STARS: _search_stars, } diff --git a/app/services/payment_service.py b/app/services/payment_service.py index 9c2e1f94..47e09270 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -34,6 +34,7 @@ from app.services.payment.aurapay import AuraPayPaymentMixin from app.services.payment.cloudpayments import CloudPaymentsPaymentMixin from app.services.payment.freekassa import FreekassaPaymentMixin from app.services.payment.kassa_ai import KassaAiPaymentMixin +from app.services.payment.overpay import OverpayPaymentMixin from app.services.payment.paypear import PayPearPaymentMixin from app.services.payment.riopay import RioPayPaymentMixin from app.services.payment.rollypay import RollyPayPaymentMixin @@ -408,6 +409,44 @@ async def link_rollypay_payment_to_transaction(*args, **kwargs): return await rollypay_crud.link_rollypay_payment_to_transaction(*args, **kwargs) +# --- Overpay CRUD wrappers --- + + +async def create_overpay_payment(*args, **kwargs): + overpay_crud = import_module('app.database.crud.overpay') + return await overpay_crud.create_overpay_payment(*args, **kwargs) + + +async def get_overpay_payment_by_order_id(*args, **kwargs): + overpay_crud = import_module('app.database.crud.overpay') + return await overpay_crud.get_overpay_payment_by_order_id(*args, **kwargs) + + +async def get_overpay_payment_by_overpay_id(*args, **kwargs): + overpay_crud = import_module('app.database.crud.overpay') + return await overpay_crud.get_overpay_payment_by_overpay_id(*args, **kwargs) + + +async def get_overpay_payment_by_id(*args, **kwargs): + overpay_crud = import_module('app.database.crud.overpay') + return await overpay_crud.get_overpay_payment_by_id(*args, **kwargs) + + +async def get_overpay_payment_by_id_for_update(*args, **kwargs): + overpay_crud = import_module('app.database.crud.overpay') + return await overpay_crud.get_overpay_payment_by_id_for_update(*args, **kwargs) + + +async def update_overpay_payment_status(*args, **kwargs): + overpay_crud = import_module('app.database.crud.overpay') + return await overpay_crud.update_overpay_payment_status(*args, **kwargs) + + +async def link_overpay_payment_to_transaction(*args, **kwargs): + overpay_crud = import_module('app.database.crud.overpay') + return await overpay_crud.link_overpay_payment_to_transaction(*args, **kwargs) + + async def create_aurapay_payment(*args, **kwargs): aurapay_crud = import_module('app.database.crud.aurapay') return await aurapay_crud.create_aurapay_payment(*args, **kwargs) @@ -468,6 +507,7 @@ class PaymentService( SeverPayPaymentMixin, PayPearPaymentMixin, RollyPayPaymentMixin, + OverpayPaymentMixin, AuraPayPaymentMixin, ): """Основной интерфейс платежей, делегирующий работу специализированным mixin-ам.""" @@ -932,6 +972,28 @@ class PaymentService( } return None + # --- Overpay ---------------------------------------------------------- + if payment_method == 'overpay': + if not settings.is_overpay_enabled(): + logger.warning('Overpay is not enabled, cannot create guest payment') + return None + + result = await self.create_overpay_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'], 'overpay') + return { + 'payment_url': result.get('payment_url'), + 'payment_id': result.get('overpay_payment_id') or result.get('order_id'), + 'provider': 'overpay', + } + return None + # --- AuraPay ---------------------------------------------------------- if payment_method == 'aurapay': if not settings.is_aurapay_enabled(): diff --git a/app/services/payment_verification_service.py b/app/services/payment_verification_service.py index f55dcb02..678b69da 100644 --- a/app/services/payment_verification_service.py +++ b/app/services/payment_verification_service.py @@ -76,6 +76,7 @@ SUPPORTED_MANUAL_CHECK_METHODS: frozenset[PaymentMethod] = frozenset( PaymentMethod.KASSA_AI, PaymentMethod.RIOPAY, PaymentMethod.SEVERPAY, + PaymentMethod.OVERPAY, } ) @@ -96,6 +97,7 @@ SUPPORTED_AUTO_CHECK_METHODS: frozenset[PaymentMethod] = frozenset( PaymentMethod.KASSA_AI, PaymentMethod.RIOPAY, PaymentMethod.SEVERPAY, + PaymentMethod.OVERPAY, } ) @@ -125,6 +127,8 @@ def method_display_name(method: PaymentMethod) -> str: return settings.get_riopay_display_name() if method == PaymentMethod.SEVERPAY: return settings.get_severpay_display_name() + if method == PaymentMethod.OVERPAY: + return settings.get_overpay_display_name() if method == PaymentMethod.TELEGRAM_STARS: return 'Telegram Stars' return method.value @@ -155,6 +159,8 @@ def _method_is_enabled(method: PaymentMethod) -> bool: return settings.is_riopay_enabled() if method == PaymentMethod.SEVERPAY: return settings.is_severpay_enabled() + if method == PaymentMethod.OVERPAY: + return settings.is_overpay_enabled() return False diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index c4d2c06b..d5976229 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -93,6 +93,7 @@ class BotConfigurationService: 'SEVERPAY': '💳 SeverPay', 'PAYPEAR': '💳 PayPear', 'ROLLYPAY': '💳 RollyPay', + 'OVERPAY': '💳 Overpay', 'AURAPAY': '💳 AuraPay', 'YOOKASSA': '🟣 YooKassa', 'PLATEGA': '💳 {platega_name}', @@ -157,6 +158,7 @@ class BotConfigurationService: 'RIOPAY': 'RioPay: платёжная система api.riopay.online с поддержкой карт и СБП.', 'PAYPEAR': 'PayPear: платёжная система api.paypear.ru с поддержкой карт, СБП, SberPay и T-Pay.', 'ROLLYPAY': 'RollyPay: платёжный шлюз rollypay.io с СБП, картами и криптовалютой.', + 'OVERPAY': 'Overpay: платёжный шлюз pay.overpay.io с mTLS и поддержкой карт и СБП.', 'AURAPAY': 'AuraPay: платёжный шлюз aurapay.tech с поддержкой карт и СБП.', 'PLATEGA': '{platega_name}: merchant ID, секрет, ссылки возврата и методы оплаты.', 'MULENPAY': 'Платежи {mulenpay_name} и параметры магазина.', @@ -371,6 +373,7 @@ class BotConfigurationService: 'SEVERPAY_': 'SEVERPAY', 'PAYPEAR_': 'PAYPEAR', 'ROLLYPAY_': 'ROLLYPAY', + 'OVERPAY_': 'OVERPAY', 'AURAPAY_': 'AURAPAY', 'PLATEGA_': 'PLATEGA', 'MULENPAY_': 'MULENPAY', diff --git a/app/webserver/payments.py b/app/webserver/payments.py index b31d937e..c075962d 100644 --- a/app/webserver/payments.py +++ b/app/webserver/payments.py @@ -1340,6 +1340,81 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute routes_registered = True + # Overpay webhook + if settings.is_overpay_enabled(): + + @router.get(settings.OVERPAY_WEBHOOK_PATH) + async def overpay_health() -> JSONResponse: + return JSONResponse( + { + 'status': 'ok', + 'service': 'overpay_webhook', + 'enabled': settings.is_overpay_enabled(), + } + ) + + @router.post(settings.OVERPAY_WEBHOOK_PATH) + async def overpay_webhook(request: Request) -> JSONResponse: + try: + raw_body = await request.body() + payload = json.loads(raw_body) + except Exception as parse_error: + logger.error('Overpay webhook: failed to parse JSON', parse_error=parse_error) + return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST) + + # Overpay uses mTLS for authentication — verify payment exists in DB + merchant_transaction_id = payload.get('merchantTransactionId') + if not merchant_transaction_id: + logger.warning('Overpay webhook: missing merchantTransactionId') + return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST) + + # Validate that the payment exists in our DB (basic anti-spoofing) + from app.database.crud.overpay import get_overpay_payment_by_order_id + + db_generator = get_db() + try: + check_db = await db_generator.__anext__() + except StopAsyncIteration: + return JSONResponse({'status': False}, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) + + try: + existing = await get_overpay_payment_by_order_id(check_db, merchant_transaction_id) + if not existing: + overpay_id = payload.get('id') + if overpay_id: + from app.database.crud.overpay import get_overpay_payment_by_overpay_id + + existing = await get_overpay_payment_by_overpay_id(check_db, str(overpay_id)) + if not existing: + logger.warning( + 'Overpay webhook: payment not found in DB', + merchant_transaction_id=merchant_transaction_id, + ) + return JSONResponse({'status': False}, status_code=status.HTTP_404_NOT_FOUND) + finally: + try: + await db_generator.__anext__() + except StopAsyncIteration: + pass + + try: + success = await _process_payment_service_callback( + payment_service, + payload, + 'process_overpay_webhook', + ) + if not success: + logger.error( + 'Overpay webhook processing failed', + data=payload.get('id'), + ) + except Exception as e: + logger.exception('Overpay webhook processing error', error=e) + # Always return 200 — Overpay expects HTTP 200 + return JSONResponse({'status': True}, status_code=status.HTTP_200_OK) + + routes_registered = True + # AuraPay webhook if settings.is_aurapay_enabled(): @@ -1411,6 +1486,7 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute 'severpay_enabled': settings.is_severpay_enabled(), 'paypear_enabled': settings.is_paypear_enabled(), 'rollypay_enabled': settings.is_rollypay_enabled(), + 'overpay_enabled': settings.is_overpay_enabled(), 'aurapay_enabled': settings.is_aurapay_enabled(), } ) diff --git a/migrations/alembic/versions/0064_create_overpay_payments.py b/migrations/alembic/versions/0064_create_overpay_payments.py new file mode 100644 index 00000000..547eab3b --- /dev/null +++ b/migrations/alembic/versions/0064_create_overpay_payments.py @@ -0,0 +1,45 @@ +"""create overpay_payments table + +Revision ID: 0064 +Revises: 0063 +Create Date: 2026-04-21 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = '0064' +down_revision: Union[str, None] = '0063' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'overpay_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('overpay_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('overpay_payments') diff --git a/uv.lock b/uv.lock index 495a55ac..af8e681f 100644 --- a/uv.lock +++ b/uv.lock @@ -1142,7 +1142,7 @@ wheels = [ [[package]] name = "remnawave-bedolaga-telegram-bot" -version = "3.46.1" +version = "3.49.0" source = { virtual = "." } dependencies = [ { name = "aiogram" },