feat: integrate Etoplatezhi payment provider
- Add etoplatezhi_service.py with HMAC-SHA512+base64 signature algorithm, payment URL builder, and callback signature verification - Add payment mixin with create/process/finalize flow, 12 status mappings - Add CRUD operations with FOR UPDATE locking, idempotency checks - Add Telegram handlers with SBP/Card sub-method selection - Add Alembic migration for etoplatezhi_payments table - Add config settings (ETOPLATEZHI_ENABLED, PROJECT_ID, SECRET_KEY, SBP_ENABLED, CARD_ENABLED, display names, min/max amounts) - Add webhook endpoint with JSON-body signature verification - Register in payment keyboard, router, utils, backup, method config
This commit is contained in:
@@ -686,6 +686,22 @@ class Settings(BaseSettings):
|
|||||||
AURAPAY_CARD_ENABLED: bool = False
|
AURAPAY_CARD_ENABLED: bool = False
|
||||||
AURAPAY_CARD_DISPLAY_NAME: str = 'Карта (AuraPay)'
|
AURAPAY_CARD_DISPLAY_NAME: str = 'Карта (AuraPay)'
|
||||||
|
|
||||||
|
# Etoplatezhi (paymentpage.etoplatezhi.ru)
|
||||||
|
ETOPLATEZHI_ENABLED: bool = False
|
||||||
|
ETOPLATEZHI_PROJECT_ID: int | None = None
|
||||||
|
ETOPLATEZHI_SECRET_KEY: str | None = None
|
||||||
|
ETOPLATEZHI_DISPLAY_NAME: str = 'Etoplatezhi'
|
||||||
|
ETOPLATEZHI_CURRENCY: str = 'RUB'
|
||||||
|
ETOPLATEZHI_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
|
||||||
|
ETOPLATEZHI_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
|
||||||
|
ETOPLATEZHI_WEBHOOK_PATH: str = '/etoplatezhi-webhook'
|
||||||
|
ETOPLATEZHI_RETURN_URL: str | None = None
|
||||||
|
ETOPLATEZHI_PAYMENT_LIFETIME_MINUTES: int = 60
|
||||||
|
ETOPLATEZHI_SBP_ENABLED: bool = False
|
||||||
|
ETOPLATEZHI_SBP_DISPLAY_NAME: str = 'СБП (Etoplatezhi)'
|
||||||
|
ETOPLATEZHI_CARD_ENABLED: bool = False
|
||||||
|
ETOPLATEZHI_CARD_DISPLAY_NAME: str = 'Карта (Etoplatezhi)'
|
||||||
|
|
||||||
MAIN_MENU_MODE: str = 'default' # 'default' | 'cabinet'
|
MAIN_MENU_MODE: str = 'default' # 'default' | 'cabinet'
|
||||||
# Стиль кнопок Cabinet: primary (синий), success (зелёный), danger (красный), '' (по умолчанию для каждой секции)
|
# Стиль кнопок Cabinet: primary (синий), success (зелёный), danger (красный), '' (по умолчанию для каждой секции)
|
||||||
CABINET_BUTTON_STYLE: str = ''
|
CABINET_BUTTON_STYLE: str = ''
|
||||||
@@ -2142,6 +2158,40 @@ class Settings(BaseSettings):
|
|||||||
def get_aurapay_card_display_name_html(self) -> str:
|
def get_aurapay_card_display_name_html(self) -> str:
|
||||||
return html.escape(self.get_aurapay_card_display_name())
|
return html.escape(self.get_aurapay_card_display_name())
|
||||||
|
|
||||||
|
def is_etoplatezhi_enabled(self) -> bool:
|
||||||
|
return (
|
||||||
|
self.ETOPLATEZHI_ENABLED
|
||||||
|
and self.ETOPLATEZHI_PROJECT_ID is not None
|
||||||
|
and self.ETOPLATEZHI_SECRET_KEY is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_etoplatezhi_display_name(self) -> str:
|
||||||
|
name = (self.ETOPLATEZHI_DISPLAY_NAME or '').strip()
|
||||||
|
return name if name else 'Etoplatezhi'
|
||||||
|
|
||||||
|
def get_etoplatezhi_display_name_html(self) -> str:
|
||||||
|
return html.escape(self.get_etoplatezhi_display_name())
|
||||||
|
|
||||||
|
def is_etoplatezhi_sbp_enabled(self) -> bool:
|
||||||
|
return self.ETOPLATEZHI_SBP_ENABLED and self.is_etoplatezhi_enabled()
|
||||||
|
|
||||||
|
def get_etoplatezhi_sbp_display_name(self) -> str:
|
||||||
|
name = (self.ETOPLATEZHI_SBP_DISPLAY_NAME or '').strip()
|
||||||
|
return name or 'СБП (Etoplatezhi)'
|
||||||
|
|
||||||
|
def get_etoplatezhi_sbp_display_name_html(self) -> str:
|
||||||
|
return html.escape(self.get_etoplatezhi_sbp_display_name())
|
||||||
|
|
||||||
|
def is_etoplatezhi_card_enabled(self) -> bool:
|
||||||
|
return self.ETOPLATEZHI_CARD_ENABLED and self.is_etoplatezhi_enabled()
|
||||||
|
|
||||||
|
def get_etoplatezhi_card_display_name(self) -> str:
|
||||||
|
name = (self.ETOPLATEZHI_CARD_DISPLAY_NAME or '').strip()
|
||||||
|
return name or 'Карта (Etoplatezhi)'
|
||||||
|
|
||||||
|
def get_etoplatezhi_card_display_name_html(self) -> str:
|
||||||
|
return html.escape(self.get_etoplatezhi_card_display_name())
|
||||||
|
|
||||||
def is_kassa_ai_sbp_enabled(self) -> bool:
|
def is_kassa_ai_sbp_enabled(self) -> bool:
|
||||||
return self.KASSA_AI_SBP_ENABLED and self.is_kassa_ai_enabled()
|
return self.KASSA_AI_SBP_ENABLED and self.is_kassa_ai_enabled()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"""CRUD операции для платежей Etoplatezhi."""
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database.models import EtoplatezhiPayment
|
||||||
|
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_etoplatezhi_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,
|
||||||
|
etoplatezhi_payment_id: str | None = None,
|
||||||
|
expires_at: datetime | None = None,
|
||||||
|
metadata_json: dict | None = None,
|
||||||
|
) -> EtoplatezhiPayment:
|
||||||
|
"""Создает запись о платеже Etoplatezhi."""
|
||||||
|
payment = EtoplatezhiPayment(
|
||||||
|
user_id=user_id,
|
||||||
|
order_id=order_id,
|
||||||
|
amount_kopeks=amount_kopeks,
|
||||||
|
currency=currency,
|
||||||
|
description=description,
|
||||||
|
payment_url=payment_url,
|
||||||
|
payment_method=payment_method,
|
||||||
|
etoplatezhi_payment_id=etoplatezhi_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('Создан платеж Etoplatezhi', order_id=order_id, user_id=user_id)
|
||||||
|
return payment
|
||||||
|
|
||||||
|
|
||||||
|
async def get_etoplatezhi_payment_by_order_id(db: AsyncSession, order_id: str) -> EtoplatezhiPayment | None:
|
||||||
|
"""Получает платеж по order_id (internal)."""
|
||||||
|
result = await db.execute(select(EtoplatezhiPayment).where(EtoplatezhiPayment.order_id == order_id))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_etoplatezhi_payment_by_invoice_id(
|
||||||
|
db: AsyncSession, etoplatezhi_payment_id: str
|
||||||
|
) -> EtoplatezhiPayment | None:
|
||||||
|
"""Получает платеж по ID от Etoplatezhi."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(EtoplatezhiPayment).where(EtoplatezhiPayment.etoplatezhi_payment_id == etoplatezhi_payment_id)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_etoplatezhi_payment_by_id(db: AsyncSession, payment_id: int) -> EtoplatezhiPayment | None:
|
||||||
|
"""Получает платеж по ID."""
|
||||||
|
result = await db.execute(select(EtoplatezhiPayment).where(EtoplatezhiPayment.id == payment_id))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_etoplatezhi_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> EtoplatezhiPayment | None:
|
||||||
|
"""Получает платеж по ID с блокировкой FOR UPDATE."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(EtoplatezhiPayment)
|
||||||
|
.where(EtoplatezhiPayment.id == payment_id)
|
||||||
|
.with_for_update()
|
||||||
|
.execution_options(populate_existing=True)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def update_etoplatezhi_payment_status(
|
||||||
|
db: AsyncSession,
|
||||||
|
payment: EtoplatezhiPayment,
|
||||||
|
*,
|
||||||
|
status: str,
|
||||||
|
is_paid: bool | None = None,
|
||||||
|
etoplatezhi_payment_id: str | None = None,
|
||||||
|
payment_method: str | None = None,
|
||||||
|
callback_payload: dict | None = None,
|
||||||
|
transaction_id: int | None = None,
|
||||||
|
) -> EtoplatezhiPayment:
|
||||||
|
"""Обновляет статус платежа."""
|
||||||
|
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 etoplatezhi_payment_id is not None:
|
||||||
|
payment.etoplatezhi_payment_id = etoplatezhi_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(
|
||||||
|
'Обновлен статус платежа Etoplatezhi',
|
||||||
|
order_id=payment.order_id,
|
||||||
|
status=status,
|
||||||
|
is_paid=payment.is_paid,
|
||||||
|
)
|
||||||
|
return payment
|
||||||
|
|
||||||
|
|
||||||
|
async def get_pending_etoplatezhi_payments(db: AsyncSession, user_id: int) -> list[EtoplatezhiPayment]:
|
||||||
|
"""Получает незавершенные платежи пользователя."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(EtoplatezhiPayment).where(
|
||||||
|
EtoplatezhiPayment.user_id == user_id,
|
||||||
|
EtoplatezhiPayment.status == 'pending',
|
||||||
|
EtoplatezhiPayment.is_paid == False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def get_expired_pending_etoplatezhi_payments(
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> list[EtoplatezhiPayment]:
|
||||||
|
"""Получает просроченные платежи в статусе pending."""
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
result = await db.execute(
|
||||||
|
select(EtoplatezhiPayment).where(
|
||||||
|
EtoplatezhiPayment.status == 'pending',
|
||||||
|
EtoplatezhiPayment.is_paid == False,
|
||||||
|
EtoplatezhiPayment.expires_at < now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def link_etoplatezhi_payment_to_transaction(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
payment: EtoplatezhiPayment,
|
||||||
|
transaction_id: int,
|
||||||
|
) -> EtoplatezhiPayment:
|
||||||
|
"""Связывает платеж с транзакцией."""
|
||||||
|
payment.transaction_id = transaction_id
|
||||||
|
payment.updated_at = datetime.now(UTC)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(payment)
|
||||||
|
return payment
|
||||||
@@ -31,6 +31,7 @@ REAL_PAYMENT_METHODS = [
|
|||||||
PaymentMethod.PAYPEAR.value,
|
PaymentMethod.PAYPEAR.value,
|
||||||
PaymentMethod.OVERPAY.value,
|
PaymentMethod.OVERPAY.value,
|
||||||
PaymentMethod.AURAPAY.value,
|
PaymentMethod.AURAPAY.value,
|
||||||
|
PaymentMethod.ETOPLATEZHI.value,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ class PaymentMethod(Enum):
|
|||||||
ROLLYPAY = 'rollypay'
|
ROLLYPAY = 'rollypay'
|
||||||
OVERPAY = 'overpay'
|
OVERPAY = 'overpay'
|
||||||
AURAPAY = 'aurapay'
|
AURAPAY = 'aurapay'
|
||||||
|
ETOPLATEZHI = 'etoplatezhi'
|
||||||
MANUAL = 'manual'
|
MANUAL = 'manual'
|
||||||
BALANCE = 'balance'
|
BALANCE = 'balance'
|
||||||
|
|
||||||
@@ -1166,6 +1167,68 @@ class AuraPayPayment(Base):
|
|||||||
return f'<AuraPayPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
return f'<AuraPayPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
||||||
|
|
||||||
|
|
||||||
|
class EtoplatezhiPayment(Base):
|
||||||
|
"""Платежи через Etoplatezhi (paymentpage.etoplatezhi.ru)."""
|
||||||
|
|
||||||
|
__tablename__ = 'etoplatezhi_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
|
||||||
|
etoplatezhi_payment_id = Column(String(128), unique=True, nullable=True, index=True) # ID от Etoplatezhi
|
||||||
|
|
||||||
|
# Суммы
|
||||||
|
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='etoplatezhi_payments')
|
||||||
|
transaction = relationship('Transaction', backref='etoplatezhi_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'<EtoplatezhiPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
||||||
|
|
||||||
|
|
||||||
class PromoGroup(Base):
|
class PromoGroup(Base):
|
||||||
__tablename__ = 'promo_groups'
|
__tablename__ = 'promo_groups'
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,302 @@
|
|||||||
|
"""Handler for Etoplatezhi 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_etoplatezhi_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 Etoplatezhi 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_etoplatezhi_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_etoplatezhi_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(
|
||||||
|
'ETOPLATEZHI_PAYMENT_CREATED',
|
||||||
|
'\U0001f4b3 <b>Оплата через {name}</b>\n\n'
|
||||||
|
'Сумма: <b>{amount}\u20bd</b>\n\n'
|
||||||
|
'Нажмите кнопку ниже для оплаты.\n'
|
||||||
|
'После успешной оплаты баланс будет пополнен автоматически.',
|
||||||
|
).format(name=display_name, amount=f'{amount_rub:.2f}')
|
||||||
|
|
||||||
|
if edit_message:
|
||||||
|
await message_or_callback.edit_text(
|
||||||
|
response_text,
|
||||||
|
reply_markup=keyboard,
|
||||||
|
parse_mode='HTML',
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await message_or_callback.answer(
|
||||||
|
response_text,
|
||||||
|
reply_markup=keyboard,
|
||||||
|
parse_mode='HTML',
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info('Etoplatezhi payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
|
||||||
|
|
||||||
|
|
||||||
|
@error_handler
|
||||||
|
async def process_etoplatezhi_payment_amount(
|
||||||
|
message: types.Message,
|
||||||
|
db_user: User,
|
||||||
|
db: AsyncSession,
|
||||||
|
amount_kopeks: int,
|
||||||
|
state: FSMContext,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Process payment amount directly.
|
||||||
|
"""
|
||||||
|
texts = get_texts(db_user.language)
|
||||||
|
|
||||||
|
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||||
|
if restriction_kb:
|
||||||
|
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||||
|
await message.answer(
|
||||||
|
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
|
||||||
|
parse_mode='HTML',
|
||||||
|
reply_markup=restriction_kb,
|
||||||
|
)
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Validate amount
|
||||||
|
min_amount = settings.ETOPLATEZHI_MIN_AMOUNT_KOPEKS
|
||||||
|
max_amount = settings.ETOPLATEZHI_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', 'etoplatezhi')
|
||||||
|
# etoplatezhi_sbp → 'sbp', etoplatezhi_card → 'card', etoplatezhi → None
|
||||||
|
payment_method_type = _extract_service_type(payment_method)
|
||||||
|
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
await _create_etoplatezhi_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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
ETOPLATEZHI_PAYMENT_METHODS = {'etoplatezhi', 'etoplatezhi_sbp', 'etoplatezhi_card'}
|
||||||
|
|
||||||
|
ETOPLATEZHI_SERVICE_MAP: dict[str, str | None] = {
|
||||||
|
'etoplatezhi': None,
|
||||||
|
'etoplatezhi_sbp': 'sbp',
|
||||||
|
'etoplatezhi_card': 'card',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_service_type(payment_method: str) -> str | None:
|
||||||
|
return ETOPLATEZHI_SERVICE_MAP.get(payment_method)
|
||||||
|
|
||||||
|
|
||||||
|
async def _start_etoplatezhi_topup_impl(
|
||||||
|
callback: types.CallbackQuery,
|
||||||
|
db_user: User,
|
||||||
|
state: FSMContext,
|
||||||
|
payment_method: str,
|
||||||
|
):
|
||||||
|
"""Common logic for starting Etoplatezhi top-up (generic / SBP / card)."""
|
||||||
|
texts = get_texts(db_user.language)
|
||||||
|
|
||||||
|
restriction_kb = _check_topup_restriction(db_user, texts)
|
||||||
|
if restriction_kb:
|
||||||
|
reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором')
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f'\U0001f6ab <b>Пополнение ограничено</b>\n\n{reason}',
|
||||||
|
parse_mode='HTML',
|
||||||
|
reply_markup=restriction_kb,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.set_state(BalanceStates.waiting_for_amount)
|
||||||
|
await state.update_data(payment_method=payment_method)
|
||||||
|
|
||||||
|
min_amount = settings.ETOPLATEZHI_MIN_AMOUNT_KOPEKS // 100
|
||||||
|
max_amount = settings.ETOPLATEZHI_MAX_AMOUNT_KOPEKS // 100
|
||||||
|
|
||||||
|
# Choose display name based on sub-method
|
||||||
|
if payment_method == 'etoplatezhi_sbp':
|
||||||
|
display_name = settings.get_etoplatezhi_sbp_display_name()
|
||||||
|
elif payment_method == 'etoplatezhi_card':
|
||||||
|
display_name = settings.get_etoplatezhi_card_display_name()
|
||||||
|
else:
|
||||||
|
display_name = settings.get_etoplatezhi_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(
|
||||||
|
'ETOPLATEZHI_ENTER_AMOUNT',
|
||||||
|
'\U0001f4b3 <b>Пополнение через {name}</b>\n\n'
|
||||||
|
'Введите сумму пополнения в рублях.\n\n'
|
||||||
|
'Минимум: {min_amount}\u20bd\n'
|
||||||
|
'Максимум: {max_amount}\u20bd',
|
||||||
|
).format(
|
||||||
|
name=display_name,
|
||||||
|
min_amount=min_amount,
|
||||||
|
max_amount=f'{max_amount:,}'.replace(',', ' '),
|
||||||
|
),
|
||||||
|
parse_mode='HTML',
|
||||||
|
reply_markup=keyboard,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@error_handler
|
||||||
|
async def start_etoplatezhi_topup(
|
||||||
|
callback: types.CallbackQuery,
|
||||||
|
db_user: User,
|
||||||
|
db: AsyncSession,
|
||||||
|
state: FSMContext,
|
||||||
|
):
|
||||||
|
await _start_etoplatezhi_topup_impl(callback, db_user, state, 'etoplatezhi')
|
||||||
|
|
||||||
|
|
||||||
|
@error_handler
|
||||||
|
async def start_etoplatezhi_sbp_topup(
|
||||||
|
callback: types.CallbackQuery,
|
||||||
|
db_user: User,
|
||||||
|
db: AsyncSession,
|
||||||
|
state: FSMContext,
|
||||||
|
):
|
||||||
|
await _start_etoplatezhi_topup_impl(callback, db_user, state, 'etoplatezhi_sbp')
|
||||||
|
|
||||||
|
|
||||||
|
@error_handler
|
||||||
|
async def start_etoplatezhi_card_topup(
|
||||||
|
callback: types.CallbackQuery,
|
||||||
|
db_user: User,
|
||||||
|
db: AsyncSession,
|
||||||
|
state: FSMContext,
|
||||||
|
):
|
||||||
|
await _start_etoplatezhi_topup_impl(callback, db_user, state, 'etoplatezhi_card')
|
||||||
@@ -177,6 +177,13 @@ async def route_payment_by_method(
|
|||||||
await process_aurapay_payment_amount(message, db_user, db, amount_kopeks, state)
|
await process_aurapay_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
if payment_method in ('etoplatezhi', 'etoplatezhi_sbp', 'etoplatezhi_card'):
|
||||||
|
from .etoplatezhi import process_etoplatezhi_payment_amount
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
await process_etoplatezhi_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||||
|
return True
|
||||||
|
|
||||||
if payment_method == 'riopay':
|
if payment_method == 'riopay':
|
||||||
from .riopay import process_riopay_payment_amount
|
from .riopay import process_riopay_payment_amount
|
||||||
|
|
||||||
@@ -774,6 +781,12 @@ def register_balance_handlers(dp: Dispatcher):
|
|||||||
dp.callback_query.register(start_aurapay_sbp_topup, F.data == 'topup_aurapay_sbp')
|
dp.callback_query.register(start_aurapay_sbp_topup, F.data == 'topup_aurapay_sbp')
|
||||||
dp.callback_query.register(start_aurapay_card_topup, F.data == 'topup_aurapay_card')
|
dp.callback_query.register(start_aurapay_card_topup, F.data == 'topup_aurapay_card')
|
||||||
|
|
||||||
|
from .etoplatezhi import start_etoplatezhi_card_topup, start_etoplatezhi_sbp_topup, start_etoplatezhi_topup
|
||||||
|
|
||||||
|
dp.callback_query.register(start_etoplatezhi_topup, F.data == 'topup_etoplatezhi')
|
||||||
|
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 .mulenpay import check_mulenpay_payment_status
|
from .mulenpay import check_mulenpay_payment_status
|
||||||
|
|
||||||
dp.callback_query.register(check_mulenpay_payment_status, F.data.startswith('check_mulenpay_'))
|
dp.callback_query.register(check_mulenpay_payment_status, F.data.startswith('check_mulenpay_'))
|
||||||
|
|||||||
@@ -1890,6 +1890,46 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
|||||||
)
|
)
|
||||||
has_direct_payment_methods = True
|
has_direct_payment_methods = True
|
||||||
|
|
||||||
|
if settings.is_etoplatezhi_sbp_enabled():
|
||||||
|
sbp_name = settings.get_etoplatezhi_sbp_display_name()
|
||||||
|
keyboard.append(
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=texts.t('PAYMENT_ETOPLATEZHI_SBP', f'📱 {sbp_name}'),
|
||||||
|
callback_data=_build_callback('etoplatezhi_sbp'),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
has_direct_payment_methods = True
|
||||||
|
|
||||||
|
if settings.is_etoplatezhi_card_enabled():
|
||||||
|
card_name = settings.get_etoplatezhi_card_display_name()
|
||||||
|
keyboard.append(
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=texts.t('PAYMENT_ETOPLATEZHI_CARD', f'💳 {card_name}'),
|
||||||
|
callback_data=_build_callback('etoplatezhi_card'),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
has_direct_payment_methods = True
|
||||||
|
|
||||||
|
if (
|
||||||
|
settings.is_etoplatezhi_enabled()
|
||||||
|
and not settings.is_etoplatezhi_sbp_enabled()
|
||||||
|
and not settings.is_etoplatezhi_card_enabled()
|
||||||
|
):
|
||||||
|
etoplatezhi_name = settings.get_etoplatezhi_display_name()
|
||||||
|
keyboard.append(
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=texts.t('PAYMENT_ETOPLATEZHI', f'💳 {etoplatezhi_name}'),
|
||||||
|
callback_data=_build_callback('etoplatezhi'),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
has_direct_payment_methods = True
|
||||||
|
|
||||||
if settings.is_support_topup_enabled():
|
if settings.is_support_topup_enabled():
|
||||||
keyboard.append(
|
keyboard.append(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -1517,6 +1517,7 @@ class BackupService:
|
|||||||
'rollypay_payments',
|
'rollypay_payments',
|
||||||
'overpay_payments',
|
'overpay_payments',
|
||||||
'aurapay_payments',
|
'aurapay_payments',
|
||||||
|
'etoplatezhi_payments',
|
||||||
'apple_transactions',
|
'apple_transactions',
|
||||||
'saved_payment_methods',
|
'saved_payment_methods',
|
||||||
# --- Content/config ---
|
# --- Content/config ---
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
"""Сервис для работы с Etoplatezhi (paymentpage.etoplatezhi.ru)."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
PAYMENT_PAGE_BASE_URL = 'https://paymentpage.etoplatezhi.ru/payment'
|
||||||
|
|
||||||
|
|
||||||
|
class EtoplatezhiService:
|
||||||
|
"""Сервис для построения URL платежей и верификации callback-ов Etoplatezhi."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def project_id(self) -> int:
|
||||||
|
return settings.ETOPLATEZHI_PROJECT_ID or 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def secret_key(self) -> str:
|
||||||
|
return settings.ETOPLATEZHI_SECRET_KEY or ''
|
||||||
|
|
||||||
|
def _flatten_params(
|
||||||
|
self,
|
||||||
|
params: dict[str, Any],
|
||||||
|
prefix: str = '',
|
||||||
|
ignore: set[str] | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Рекурсивно «сплющивает» вложенные словари в список 'key:value' строк.
|
||||||
|
|
||||||
|
Keys разделяются двоеточием. ``frame_mode`` и ``signature`` игнорируются.
|
||||||
|
Booleans приводятся к '1'/'0'.
|
||||||
|
Empty arrays (lists) are excluded entirely per Etoplatezhi spec.
|
||||||
|
"""
|
||||||
|
if ignore is None:
|
||||||
|
ignore = {'frame_mode', 'signature'}
|
||||||
|
|
||||||
|
entries: list[str] = []
|
||||||
|
for key, value in params.items():
|
||||||
|
full_key = f'{prefix}:{key}' if prefix else key
|
||||||
|
if full_key in ignore or key in ignore:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if isinstance(value, dict):
|
||||||
|
entries.extend(self._flatten_params(value, prefix=full_key, ignore=ignore))
|
||||||
|
elif isinstance(value, list):
|
||||||
|
# Empty arrays are excluded entirely per spec
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
# Non-empty arrays: flatten each element with index as key
|
||||||
|
for idx, item in enumerate(value):
|
||||||
|
item_key = f'{full_key}:{idx}'
|
||||||
|
if isinstance(item, dict):
|
||||||
|
entries.extend(self._flatten_params(item, prefix=item_key, ignore=ignore))
|
||||||
|
elif isinstance(item, bool):
|
||||||
|
entries.append(f'{item_key}:{"1" if item else "0"}')
|
||||||
|
elif item is not None:
|
||||||
|
entries.append(f'{item_key}:{item}')
|
||||||
|
elif isinstance(value, bool):
|
||||||
|
entries.append(f'{full_key}:{"1" if value else "0"}')
|
||||||
|
elif value is not None:
|
||||||
|
entries.append(f'{full_key}:{value}')
|
||||||
|
|
||||||
|
return entries
|
||||||
|
|
||||||
|
def _sign(self, params: dict[str, Any]) -> str:
|
||||||
|
"""HMAC-SHA512 + base64 подпись параметров.
|
||||||
|
|
||||||
|
Algorithm:
|
||||||
|
1. Flatten nested dicts with ':' separator.
|
||||||
|
2. Each leaf → "key:value".
|
||||||
|
3. Sort alphabetically by full key string.
|
||||||
|
4. Join with ';'.
|
||||||
|
5. HMAC-SHA512 with secret_key.
|
||||||
|
6. base64-encode the raw digest.
|
||||||
|
"""
|
||||||
|
entries = self._flatten_params(params)
|
||||||
|
entries.sort()
|
||||||
|
message = ';'.join(entries)
|
||||||
|
|
||||||
|
digest = hmac.new(
|
||||||
|
self.secret_key.encode('utf-8'),
|
||||||
|
message.encode('utf-8'),
|
||||||
|
hashlib.sha512,
|
||||||
|
).digest()
|
||||||
|
|
||||||
|
return base64.b64encode(digest).decode('utf-8')
|
||||||
|
|
||||||
|
def build_payment_url(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
project_id: int,
|
||||||
|
payment_id: str,
|
||||||
|
payment_amount: int,
|
||||||
|
payment_currency: str = 'RUB',
|
||||||
|
customer_id: str,
|
||||||
|
description: str | None = None,
|
||||||
|
callback_url: str | None = None,
|
||||||
|
success_url: str | None = None,
|
||||||
|
fail_url: str | None = None,
|
||||||
|
force_payment_method: str | None = None,
|
||||||
|
customer_email: str | None = None,
|
||||||
|
language_code: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Строит URL для редиректа на платёжную страницу Etoplatezhi.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: ID проекта в Etoplatezhi.
|
||||||
|
payment_id: Наш internal order_id.
|
||||||
|
payment_amount: Сумма в минорных единицах (копейках).
|
||||||
|
payment_currency: ISO 4217 код валюты.
|
||||||
|
customer_id: Telegram ID или guest-идентификатор покупателя.
|
||||||
|
description: Описание платежа.
|
||||||
|
callback_url: URL для callback (POST JSON).
|
||||||
|
success_url: URL редиректа при успехе.
|
||||||
|
fail_url: URL редиректа при ошибке.
|
||||||
|
force_payment_method: 'sbp' или 'card' для принудительного выбора.
|
||||||
|
customer_email: Email покупателя.
|
||||||
|
language_code: Язык интерфейса ('ru', 'en').
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Полный URL с параметрами и подписью.
|
||||||
|
"""
|
||||||
|
params: dict[str, Any] = {
|
||||||
|
'project_id': project_id,
|
||||||
|
'payment_id': payment_id,
|
||||||
|
'payment_amount': payment_amount,
|
||||||
|
'payment_currency': payment_currency,
|
||||||
|
'customer_id': customer_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
if description:
|
||||||
|
params['payment_description'] = description
|
||||||
|
if callback_url:
|
||||||
|
params['merchant_callback_url'] = callback_url
|
||||||
|
if success_url:
|
||||||
|
params['redirect_success_url'] = success_url
|
||||||
|
if fail_url:
|
||||||
|
params['redirect_fail_url'] = fail_url
|
||||||
|
if force_payment_method:
|
||||||
|
params['force_payment_method'] = force_payment_method
|
||||||
|
if customer_email:
|
||||||
|
params['customer_email'] = customer_email
|
||||||
|
if language_code:
|
||||||
|
params['language_code'] = language_code
|
||||||
|
|
||||||
|
params['signature'] = self._sign(params)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
'Etoplatezhi: building payment URL',
|
||||||
|
payment_id=payment_id,
|
||||||
|
payment_amount=payment_amount,
|
||||||
|
customer_id=customer_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return f'{PAYMENT_PAGE_BASE_URL}?{urlencode(params)}'
|
||||||
|
|
||||||
|
def verify_callback_signature(self, payload: dict[str, Any]) -> bool:
|
||||||
|
"""Верифицирует подпись в callback-е Etoplatezhi.
|
||||||
|
|
||||||
|
Подпись находится внутри JSON body (поле ``signature``).
|
||||||
|
Для проверки: удаляем ``signature`` из всех уровней вложенности,
|
||||||
|
вычисляем подпись по оставшимся данным и сравниваем.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
received_signature = payload.get('signature')
|
||||||
|
if not received_signature:
|
||||||
|
logger.warning('Etoplatezhi callback: отсутствует signature в payload')
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Deep-copy payload and strip all 'signature' keys recursively
|
||||||
|
cleaned = self._strip_signature_keys(payload)
|
||||||
|
|
||||||
|
expected = self._sign(cleaned)
|
||||||
|
return hmac.compare_digest(expected, str(received_signature))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Etoplatezhi callback verify error', error=e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _strip_signature_keys(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Рекурсивно удаляет ключ ``signature`` из словаря и вложенных словарей."""
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
for key, value in data.items():
|
||||||
|
if key == 'signature':
|
||||||
|
continue
|
||||||
|
if isinstance(value, dict):
|
||||||
|
result[key] = self._strip_signature_keys(value)
|
||||||
|
else:
|
||||||
|
result[key] = value
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
etoplatezhi_service = EtoplatezhiService()
|
||||||
@@ -0,0 +1,518 @@
|
|||||||
|
"""Mixin для интеграции с Etoplatezhi (paymentpage.etoplatezhi.ru)."""
|
||||||
|
|
||||||
|
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.etoplatezhi_service import etoplatezhi_service
|
||||||
|
from app.utils.payment_logger import payment_logger as logger
|
||||||
|
from app.utils.user_utils import format_referrer_info
|
||||||
|
|
||||||
|
|
||||||
|
# Маппинг статусов Etoplatezhi -> internal
|
||||||
|
ETOPLATEZHI_STATUS_MAP: dict[str, tuple[str, bool]] = {
|
||||||
|
'success': ('success', True),
|
||||||
|
'decline': ('declined', False),
|
||||||
|
'error': ('error', False),
|
||||||
|
'processing': ('pending', False),
|
||||||
|
'awaiting 3ds result': ('pending', False),
|
||||||
|
'awaiting redirect result': ('pending', False),
|
||||||
|
'awaiting clarification': ('pending', False),
|
||||||
|
'awaiting customer action': ('pending', False),
|
||||||
|
'cancelled': ('cancelled', False),
|
||||||
|
'refunded': ('refunded', False),
|
||||||
|
'partially refunded': ('partially_refunded', False),
|
||||||
|
'reversed': ('reversed', False),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class EtoplatezhiPaymentMixin:
|
||||||
|
"""Mixin для работы с платежами Etoplatezhi."""
|
||||||
|
|
||||||
|
async def create_etoplatezhi_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:
|
||||||
|
"""
|
||||||
|
Создает платеж Etoplatezhi.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Словарь с данными платежа или None при ошибке
|
||||||
|
"""
|
||||||
|
if not settings.is_etoplatezhi_enabled():
|
||||||
|
logger.error('Etoplatezhi не настроен')
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Валидация лимитов
|
||||||
|
if amount_kopeks < settings.ETOPLATEZHI_MIN_AMOUNT_KOPEKS:
|
||||||
|
logger.warning(
|
||||||
|
'Etoplatezhi: сумма меньше минимальной',
|
||||||
|
amount_kopeks=amount_kopeks,
|
||||||
|
ETOPLATEZHI_MIN_AMOUNT_KOPEKS=settings.ETOPLATEZHI_MIN_AMOUNT_KOPEKS,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if amount_kopeks > settings.ETOPLATEZHI_MAX_AMOUNT_KOPEKS:
|
||||||
|
logger.warning(
|
||||||
|
'Etoplatezhi: сумма больше максимальной',
|
||||||
|
amount_kopeks=amount_kopeks,
|
||||||
|
ETOPLATEZHI_MAX_AMOUNT_KOPEKS=settings.ETOPLATEZHI_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'etp{tg_id}_{uuid.uuid4().hex[:6]}'
|
||||||
|
amount_rubles = amount_kopeks / 100
|
||||||
|
currency = settings.ETOPLATEZHI_CURRENCY
|
||||||
|
|
||||||
|
# Метаданные
|
||||||
|
metadata = {
|
||||||
|
'user_id': user_id,
|
||||||
|
'amount_kopeks': amount_kopeks,
|
||||||
|
'description': description,
|
||||||
|
'language': language,
|
||||||
|
'type': 'balance_topup',
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Формируем webhook URL
|
||||||
|
webhook_url = None
|
||||||
|
if settings.WEBHOOK_URL:
|
||||||
|
webhook_url = f'{settings.WEBHOOK_URL.rstrip("/")}{settings.ETOPLATEZHI_WEBHOOK_PATH}'
|
||||||
|
|
||||||
|
lifetime = settings.ETOPLATEZHI_PAYMENT_LIFETIME_MINUTES
|
||||||
|
|
||||||
|
# Определяем force_payment_method по типу подметода
|
||||||
|
force_method = None
|
||||||
|
if payment_method_type == 'sbp':
|
||||||
|
force_method = 'sbp'
|
||||||
|
elif payment_method_type == 'card':
|
||||||
|
force_method = 'card'
|
||||||
|
|
||||||
|
# Строим URL для редиректа на платёжную страницу
|
||||||
|
payment_url = etoplatezhi_service.build_payment_url(
|
||||||
|
project_id=settings.ETOPLATEZHI_PROJECT_ID or 0,
|
||||||
|
payment_id=order_id,
|
||||||
|
payment_amount=amount_kopeks,
|
||||||
|
payment_currency=currency,
|
||||||
|
customer_id=str(tg_id),
|
||||||
|
description=description,
|
||||||
|
callback_url=webhook_url,
|
||||||
|
success_url=return_url or settings.ETOPLATEZHI_RETURN_URL,
|
||||||
|
fail_url=return_url or settings.ETOPLATEZHI_RETURN_URL,
|
||||||
|
force_payment_method=force_method,
|
||||||
|
customer_email=email,
|
||||||
|
language_code=language,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
'Etoplatezhi: сформирован URL платежа',
|
||||||
|
order_id=order_id,
|
||||||
|
payment_url=payment_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
expires_at = datetime.now(UTC) + timedelta(minutes=lifetime)
|
||||||
|
|
||||||
|
# Сохраняем в БД
|
||||||
|
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
|
||||||
|
local_payment = await etoplatezhi_crud.create_etoplatezhi_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,
|
||||||
|
etoplatezhi_payment_id=None,
|
||||||
|
expires_at=expires_at,
|
||||||
|
metadata_json=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
'Etoplatezhi: создан платеж',
|
||||||
|
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,
|
||||||
|
'expires_at': expires_at.isoformat(),
|
||||||
|
'local_payment_id': local_payment.id,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception('Etoplatezhi: ошибка создания платежа', error=e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def process_etoplatezhi_callback(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Обрабатывает callback от Etoplatezhi.
|
||||||
|
|
||||||
|
Подпись проверяется в webserver/payments.py до вызова этого метода.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Сессия БД
|
||||||
|
payload: JSON тело callback (signature проверена в webserver)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True если платеж успешно обработан
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Etoplatezhi callback structure:
|
||||||
|
# {project_id, payment: {id, status, sum: {amount, currency}}, customer: {id}, signature}
|
||||||
|
payment_data = payload.get('payment', {})
|
||||||
|
etoplatezhi_payment_id = payment_data.get('id')
|
||||||
|
etoplatezhi_status = payment_data.get('status')
|
||||||
|
|
||||||
|
# payment.id в callback — это наш payment_id (order_id)
|
||||||
|
our_payment_id = str(etoplatezhi_payment_id) if etoplatezhi_payment_id else None
|
||||||
|
|
||||||
|
if not our_payment_id or not etoplatezhi_status:
|
||||||
|
logger.warning('Etoplatezhi callback: отсутствуют обязательные поля', payload=payload)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Определяем is_paid по статусу
|
||||||
|
is_confirmed = etoplatezhi_status == 'success'
|
||||||
|
|
||||||
|
# Ищем платеж по order_id (наш payment_id = order_id)
|
||||||
|
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
|
||||||
|
payment = await etoplatezhi_crud.get_etoplatezhi_payment_by_order_id(db, our_payment_id)
|
||||||
|
|
||||||
|
if not payment:
|
||||||
|
logger.warning(
|
||||||
|
'Etoplatezhi callback: платеж не найден',
|
||||||
|
payment_id=our_payment_id,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Lock payment row immediately to prevent concurrent webhook processing (TOCTOU race)
|
||||||
|
locked = await etoplatezhi_crud.get_etoplatezhi_payment_by_id_for_update(db, payment.id)
|
||||||
|
if not locked:
|
||||||
|
logger.error('Etoplatezhi: не удалось заблокировать платёж', payment_id=payment.id)
|
||||||
|
return False
|
||||||
|
payment = locked
|
||||||
|
|
||||||
|
# Проверка дублирования (re-check from locked row)
|
||||||
|
if payment.is_paid:
|
||||||
|
logger.info('Etoplatezhi callback: платеж уже обработан', order_id=payment.order_id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Маппинг статуса
|
||||||
|
status_info = ETOPLATEZHI_STATUS_MAP.get(etoplatezhi_status, ('pending', False))
|
||||||
|
internal_status, is_paid = status_info
|
||||||
|
|
||||||
|
# Если статус success, принудительно считаем оплаченным
|
||||||
|
if is_confirmed:
|
||||||
|
is_paid = True
|
||||||
|
internal_status = 'success'
|
||||||
|
|
||||||
|
# Извлекаем сумму из callback: payment.sum.amount (в минорных единицах)
|
||||||
|
sum_data = payment_data.get('sum', {})
|
||||||
|
|
||||||
|
callback_payload = {
|
||||||
|
'etoplatezhi_payment_id': etoplatezhi_payment_id,
|
||||||
|
'status': etoplatezhi_status,
|
||||||
|
'sum': sum_data,
|
||||||
|
'customer': payload.get('customer'),
|
||||||
|
'project_id': payload.get('project_id'),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Проверка суммы ДО обновления статуса
|
||||||
|
if is_paid:
|
||||||
|
amount_value = sum_data.get('amount')
|
||||||
|
if amount_value is not None:
|
||||||
|
# amount в минорных единицах (копейках)
|
||||||
|
received_kopeks = int(amount_value)
|
||||||
|
if abs(received_kopeks - payment.amount_kopeks) > 1:
|
||||||
|
logger.error(
|
||||||
|
'Etoplatezhi amount mismatch',
|
||||||
|
expected_kopeks=payment.amount_kopeks,
|
||||||
|
received_kopeks=received_kopeks,
|
||||||
|
order_id=payment.order_id,
|
||||||
|
)
|
||||||
|
await etoplatezhi_crud.update_etoplatezhi_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.etoplatezhi_payment_id = str(etoplatezhi_payment_id) if etoplatezhi_payment_id else None
|
||||||
|
payment.callback_payload = callback_payload
|
||||||
|
payment.updated_at = datetime.now(UTC)
|
||||||
|
await db.flush()
|
||||||
|
return await self._finalize_etoplatezhi_payment(db, payment, trigger='webhook')
|
||||||
|
|
||||||
|
# Для не-success статусов можно безопасно коммитить
|
||||||
|
payment = await etoplatezhi_crud.update_etoplatezhi_payment_status(
|
||||||
|
db=db,
|
||||||
|
payment=payment,
|
||||||
|
status=internal_status,
|
||||||
|
is_paid=False,
|
||||||
|
callback_payload=callback_payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception('Etoplatezhi callback: ошибка обработки', error=e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _finalize_etoplatezhi_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')
|
||||||
|
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
|
||||||
|
|
||||||
|
# FOR UPDATE lock already acquired by caller — just check idempotency
|
||||||
|
if payment.transaction_id:
|
||||||
|
logger.info(
|
||||||
|
'Etoplatezhi платеж уже связан с транзакцией',
|
||||||
|
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='etoplatezhi',
|
||||||
|
)
|
||||||
|
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('Пользователь не найден для Etoplatezhi', 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.ETOPLATEZHI,
|
||||||
|
)
|
||||||
|
|
||||||
|
display_name = settings.get_etoplatezhi_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.ETOPLATEZHI,
|
||||||
|
external_id=transaction_external_id,
|
||||||
|
is_completed=True,
|
||||||
|
created_at=getattr(payment, 'created_at', None),
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
created_transaction = True
|
||||||
|
|
||||||
|
await etoplatezhi_crud.link_etoplatezhi_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('Etoplatezhi платеж уже зачислил баланс ранее', 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.ETOPLATEZHI,
|
||||||
|
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('Ошибка обработки реферального пополнения Etoplatezhi', 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('Ошибка отправки админ уведомления Etoplatezhi', error=error)
|
||||||
|
|
||||||
|
if getattr(self, 'bot', None) and user.telegram_id:
|
||||||
|
try:
|
||||||
|
keyboard = await self.build_topup_success_keyboard(user)
|
||||||
|
await self.bot.send_message(
|
||||||
|
user.telegram_id,
|
||||||
|
(
|
||||||
|
'\u2705 <b>Пополнение успешно!</b>\n\n'
|
||||||
|
f'\U0001f4b0 Сумма: {settings.format_price(payment.amount_kopeks)}\n'
|
||||||
|
f'\U0001f4b3 Способ: {display_name}\n'
|
||||||
|
f'\U0001f194 Транзакция: {transaction.id}\n\n'
|
||||||
|
'Баланс пополнен автоматически!'
|
||||||
|
),
|
||||||
|
parse_mode='HTML',
|
||||||
|
reply_markup=keyboard,
|
||||||
|
)
|
||||||
|
except Exception as error:
|
||||||
|
logger.error('Ошибка отправки уведомления пользователю Etoplatezhi', 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(
|
||||||
|
'Обработан Etoplatezhi платеж',
|
||||||
|
order_id=payment.order_id,
|
||||||
|
user_id=payment.user_id,
|
||||||
|
trigger=trigger,
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
@@ -189,6 +189,16 @@ def _get_method_defaults() -> dict:
|
|||||||
{'id': 'sbp', 'name': 'СБП'},
|
{'id': 'sbp', 'name': 'СБП'},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
'etoplatezhi': {
|
||||||
|
'default_display_name': settings.get_etoplatezhi_display_name(),
|
||||||
|
'is_configured': settings.is_etoplatezhi_enabled(),
|
||||||
|
'default_min': settings.ETOPLATEZHI_MIN_AMOUNT_KOPEKS,
|
||||||
|
'default_max': settings.ETOPLATEZHI_MAX_AMOUNT_KOPEKS,
|
||||||
|
'available_sub_options': [
|
||||||
|
{'id': 'card', 'name': 'Карта'},
|
||||||
|
{'id': 'sbp', 'name': 'СБП'},
|
||||||
|
],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -235,6 +245,7 @@ DEFAULT_METHOD_ORDER = [
|
|||||||
'rollypay',
|
'rollypay',
|
||||||
'overpay',
|
'overpay',
|
||||||
'aurapay',
|
'aurapay',
|
||||||
|
'etoplatezhi',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ from app.services.payment import (
|
|||||||
)
|
)
|
||||||
from app.services.payment.aurapay import AuraPayPaymentMixin
|
from app.services.payment.aurapay import AuraPayPaymentMixin
|
||||||
from app.services.payment.cloudpayments import CloudPaymentsPaymentMixin
|
from app.services.payment.cloudpayments import CloudPaymentsPaymentMixin
|
||||||
|
from app.services.payment.etoplatezhi import EtoplatezhiPaymentMixin
|
||||||
from app.services.payment.freekassa import FreekassaPaymentMixin
|
from app.services.payment.freekassa import FreekassaPaymentMixin
|
||||||
from app.services.payment.kassa_ai import KassaAiPaymentMixin
|
from app.services.payment.kassa_ai import KassaAiPaymentMixin
|
||||||
from app.services.payment.overpay import OverpayPaymentMixin
|
from app.services.payment.overpay import OverpayPaymentMixin
|
||||||
@@ -482,6 +483,41 @@ async def link_aurapay_payment_to_transaction(*args, **kwargs):
|
|||||||
return await aurapay_crud.link_aurapay_payment_to_transaction(*args, **kwargs)
|
return await aurapay_crud.link_aurapay_payment_to_transaction(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_etoplatezhi_payment(*args, **kwargs):
|
||||||
|
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
|
||||||
|
return await etoplatezhi_crud.create_etoplatezhi_payment(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_etoplatezhi_payment_by_order_id(*args, **kwargs):
|
||||||
|
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
|
||||||
|
return await etoplatezhi_crud.get_etoplatezhi_payment_by_order_id(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_etoplatezhi_payment_by_invoice_id(*args, **kwargs):
|
||||||
|
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
|
||||||
|
return await etoplatezhi_crud.get_etoplatezhi_payment_by_invoice_id(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_etoplatezhi_payment_by_id(*args, **kwargs):
|
||||||
|
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
|
||||||
|
return await etoplatezhi_crud.get_etoplatezhi_payment_by_id(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_etoplatezhi_payment_by_id_for_update(*args, **kwargs):
|
||||||
|
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
|
||||||
|
return await etoplatezhi_crud.get_etoplatezhi_payment_by_id_for_update(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_etoplatezhi_payment_status(*args, **kwargs):
|
||||||
|
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
|
||||||
|
return await etoplatezhi_crud.update_etoplatezhi_payment_status(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
async def link_etoplatezhi_payment_to_transaction(*args, **kwargs):
|
||||||
|
etoplatezhi_crud = import_module('app.database.crud.etoplatezhi')
|
||||||
|
return await etoplatezhi_crud.link_etoplatezhi_payment_to_transaction(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
# Mapping from model_name to getter function name for providers
|
# Mapping from model_name to getter function name for providers
|
||||||
# where it differs from the standard get_{model_name}_payment_by_id pattern.
|
# where it differs from the standard get_{model_name}_payment_by_id pattern.
|
||||||
_GETTER_OVERRIDES: dict[str, str] = {
|
_GETTER_OVERRIDES: dict[str, str] = {
|
||||||
@@ -509,6 +545,7 @@ class PaymentService(
|
|||||||
RollyPayPaymentMixin,
|
RollyPayPaymentMixin,
|
||||||
OverpayPaymentMixin,
|
OverpayPaymentMixin,
|
||||||
AuraPayPaymentMixin,
|
AuraPayPaymentMixin,
|
||||||
|
EtoplatezhiPaymentMixin,
|
||||||
):
|
):
|
||||||
"""Основной интерфейс платежей, делегирующий работу специализированным mixin-ам."""
|
"""Основной интерфейс платежей, делегирующий работу специализированным mixin-ам."""
|
||||||
|
|
||||||
@@ -1016,6 +1053,28 @@ class PaymentService(
|
|||||||
}
|
}
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# --- Etoplatezhi ------------------------------------------------------
|
||||||
|
if payment_method == 'etoplatezhi':
|
||||||
|
if not settings.is_etoplatezhi_enabled():
|
||||||
|
logger.warning('Etoplatezhi is not enabled, cannot create guest payment')
|
||||||
|
return None
|
||||||
|
|
||||||
|
result = await self.create_etoplatezhi_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'], 'etoplatezhi')
|
||||||
|
return {
|
||||||
|
'payment_url': result.get('payment_url'),
|
||||||
|
'payment_id': result.get('order_id'),
|
||||||
|
'provider': 'etoplatezhi',
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
# --- Telegram Stars ---------------------------------------------------
|
# --- Telegram Stars ---------------------------------------------------
|
||||||
if payment_method == 'telegram_stars':
|
if payment_method == 'telegram_stars':
|
||||||
if not settings.TELEGRAM_STARS_ENABLED:
|
if not settings.TELEGRAM_STARS_ENABLED:
|
||||||
|
|||||||
@@ -272,6 +272,46 @@ def get_available_payment_methods() -> list[dict[str, str]]:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if settings.is_etoplatezhi_sbp_enabled():
|
||||||
|
sbp_name = settings.get_etoplatezhi_sbp_display_name()
|
||||||
|
methods.append(
|
||||||
|
{
|
||||||
|
'id': 'etoplatezhi_sbp',
|
||||||
|
'name': sbp_name,
|
||||||
|
'icon': '📱',
|
||||||
|
'description': f'через {sbp_name}',
|
||||||
|
'callback': 'topup_etoplatezhi_sbp',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if settings.is_etoplatezhi_card_enabled():
|
||||||
|
card_name = settings.get_etoplatezhi_card_display_name()
|
||||||
|
methods.append(
|
||||||
|
{
|
||||||
|
'id': 'etoplatezhi_card',
|
||||||
|
'name': card_name,
|
||||||
|
'icon': '💳',
|
||||||
|
'description': f'через {card_name}',
|
||||||
|
'callback': 'topup_etoplatezhi_card',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
settings.is_etoplatezhi_enabled()
|
||||||
|
and not settings.is_etoplatezhi_sbp_enabled()
|
||||||
|
and not settings.is_etoplatezhi_card_enabled()
|
||||||
|
):
|
||||||
|
etoplatezhi_name = settings.get_etoplatezhi_display_name()
|
||||||
|
methods.append(
|
||||||
|
{
|
||||||
|
'id': 'etoplatezhi',
|
||||||
|
'name': etoplatezhi_name,
|
||||||
|
'icon': '💳',
|
||||||
|
'description': f'через {etoplatezhi_name}',
|
||||||
|
'callback': 'topup_etoplatezhi',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
if settings.is_support_topup_enabled():
|
if settings.is_support_topup_enabled():
|
||||||
methods.append(
|
methods.append(
|
||||||
{
|
{
|
||||||
@@ -413,6 +453,12 @@ def is_payment_method_available(method_id: str) -> bool:
|
|||||||
return settings.is_aurapay_sbp_enabled()
|
return settings.is_aurapay_sbp_enabled()
|
||||||
if method_id == 'aurapay_card':
|
if method_id == 'aurapay_card':
|
||||||
return settings.is_aurapay_card_enabled()
|
return settings.is_aurapay_card_enabled()
|
||||||
|
if method_id == 'etoplatezhi':
|
||||||
|
return settings.is_etoplatezhi_enabled()
|
||||||
|
if method_id == 'etoplatezhi_sbp':
|
||||||
|
return settings.is_etoplatezhi_sbp_enabled()
|
||||||
|
if method_id == 'etoplatezhi_card':
|
||||||
|
return settings.is_etoplatezhi_card_enabled()
|
||||||
if method_id == 'support':
|
if method_id == 'support':
|
||||||
return settings.is_support_topup_enabled()
|
return settings.is_support_topup_enabled()
|
||||||
return False
|
return False
|
||||||
@@ -443,6 +489,9 @@ def get_payment_method_status() -> dict[str, bool]:
|
|||||||
'aurapay': settings.is_aurapay_enabled(),
|
'aurapay': settings.is_aurapay_enabled(),
|
||||||
'aurapay_sbp': settings.is_aurapay_sbp_enabled(),
|
'aurapay_sbp': settings.is_aurapay_sbp_enabled(),
|
||||||
'aurapay_card': settings.is_aurapay_card_enabled(),
|
'aurapay_card': settings.is_aurapay_card_enabled(),
|
||||||
|
'etoplatezhi': settings.is_etoplatezhi_enabled(),
|
||||||
|
'etoplatezhi_sbp': settings.is_etoplatezhi_sbp_enabled(),
|
||||||
|
'etoplatezhi_card': settings.is_etoplatezhi_card_enabled(),
|
||||||
'support': settings.is_support_topup_enabled(),
|
'support': settings.is_support_topup_enabled(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -488,4 +537,6 @@ def get_enabled_payment_methods_count() -> int:
|
|||||||
count += 1
|
count += 1
|
||||||
if settings.is_aurapay_enabled():
|
if settings.is_aurapay_enabled():
|
||||||
count += 1
|
count += 1
|
||||||
|
if settings.is_etoplatezhi_enabled():
|
||||||
|
count += 1
|
||||||
return count
|
return count
|
||||||
|
|||||||
@@ -1469,6 +1469,53 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
|||||||
|
|
||||||
routes_registered = True
|
routes_registered = True
|
||||||
|
|
||||||
|
# Etoplatezhi webhook
|
||||||
|
if settings.is_etoplatezhi_enabled():
|
||||||
|
|
||||||
|
@router.get(settings.ETOPLATEZHI_WEBHOOK_PATH)
|
||||||
|
async def etoplatezhi_health() -> JSONResponse:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
'status': 'ok',
|
||||||
|
'service': 'etoplatezhi_webhook',
|
||||||
|
'enabled': settings.is_etoplatezhi_enabled(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post(settings.ETOPLATEZHI_WEBHOOK_PATH)
|
||||||
|
async def etoplatezhi_webhook(request: Request) -> JSONResponse:
|
||||||
|
try:
|
||||||
|
raw_body = await request.body()
|
||||||
|
payload = json.loads(raw_body)
|
||||||
|
except Exception as parse_error:
|
||||||
|
logger.error('Etoplatezhi webhook: failed to parse JSON', parse_error=parse_error)
|
||||||
|
return JSONResponse({'status': False}, status_code=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# Подпись внутри JSON body (поле signature)
|
||||||
|
from app.services.etoplatezhi_service import etoplatezhi_service
|
||||||
|
|
||||||
|
if not etoplatezhi_service.verify_callback_signature(payload):
|
||||||
|
logger.warning('Etoplatezhi 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_etoplatezhi_callback',
|
||||||
|
)
|
||||||
|
if not success:
|
||||||
|
logger.error(
|
||||||
|
'Etoplatezhi webhook processing failed',
|
||||||
|
data=payload.get('payment', {}).get('id'),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception('Etoplatezhi webhook processing error', error=e)
|
||||||
|
# Always return 200 — Etoplatezhi expects 200 for valid signature
|
||||||
|
return JSONResponse({'status': True}, status_code=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
routes_registered = True
|
||||||
|
|
||||||
if routes_registered:
|
if routes_registered:
|
||||||
|
|
||||||
@router.get('/health/payment-webhooks')
|
@router.get('/health/payment-webhooks')
|
||||||
@@ -1493,6 +1540,7 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
|||||||
'rollypay_enabled': settings.is_rollypay_enabled(),
|
'rollypay_enabled': settings.is_rollypay_enabled(),
|
||||||
'overpay_enabled': settings.is_overpay_enabled(),
|
'overpay_enabled': settings.is_overpay_enabled(),
|
||||||
'aurapay_enabled': settings.is_aurapay_enabled(),
|
'aurapay_enabled': settings.is_aurapay_enabled(),
|
||||||
|
'etoplatezhi_enabled': settings.is_etoplatezhi_enabled(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""create etoplatezhi_payments table
|
||||||
|
|
||||||
|
Revision ID: 0069
|
||||||
|
Revises: 0068
|
||||||
|
Create Date: 2026-05-04
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = '0069'
|
||||||
|
down_revision: Union[str, None] = '0068'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'etoplatezhi_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('etoplatezhi_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('etoplatezhi_payments')
|
||||||
Reference in New Issue
Block a user