feat: integrate Lava Business payment provider
- Lava Business via gate.lava.ru (HMAC-SHA256 signed JSON requests) - Sub-methods: card and SBP via includeService filter - Webhook signature verified from raw bytes with secret_key_2 - Sticky terminal-status guard (success after amount_mismatch escalates to ERROR) - Order ID with full uuid4 hex (128-bit entropy) - Cross-row contamination guard: order_id assertion on invoice_id fallback - Warning when hook URL cannot be derived from webhook/web_api/cabinet bases - Explicit failure when Lava response lacks payment_url (no orphan rows) - Adds LAVA settings category, /lava-webhook endpoint, cabinet topup branch - Mirrors existing Antilopay/Jupiter/Donut mixin pattern
This commit is contained in:
@@ -976,6 +976,37 @@ async def create_topup(
|
||||
detail='Failed to create Donut payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'lava':
|
||||
if not settings.is_lava_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Lava payment method is unavailable',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
payment_method_type = request.payment_option or None
|
||||
result = await payment_service.create_lava_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,
|
||||
payment_method_type=payment_method_type,
|
||||
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 Lava payment',
|
||||
)
|
||||
|
||||
else:
|
||||
# For other payment methods, redirect to bot
|
||||
raise HTTPException(
|
||||
@@ -1151,6 +1182,21 @@ def _get_status_info(record: PendingPayment) -> tuple[str, str]:
|
||||
}
|
||||
return mapping.get(status, ('❓', 'Неизвестно'))
|
||||
|
||||
if record.method == PaymentMethod.LAVA:
|
||||
mapping = {
|
||||
'pending': ('⏳', 'Ожидает оплаты'),
|
||||
'created': ('⏳', 'Создано'),
|
||||
'processing': ('⌛', 'Обрабатывается'),
|
||||
'success': ('✅', 'Оплачено'),
|
||||
'cancel': ('❌', 'Отменено'),
|
||||
'cancelled': ('❌', 'Отменено'),
|
||||
'expired': ('⌛', 'Истёк'),
|
||||
'failed': ('❌', 'Ошибка'),
|
||||
'error': ('❌', 'Ошибка'),
|
||||
'amount_mismatch': ('⚠️', 'Несовпадение суммы'),
|
||||
}
|
||||
return mapping.get(status, ('❓', 'Неизвестно'))
|
||||
|
||||
return '❓', 'Неизвестно'
|
||||
|
||||
|
||||
|
||||
@@ -753,6 +753,25 @@ class Settings(BaseSettings):
|
||||
DONUT_SBP_QR_ENABLED: bool = False
|
||||
DONUT_SBP_QR_DISPLAY_NAME: str = 'СБП QR (Donut)'
|
||||
|
||||
# Lava (Lava Business API, gate.lava.ru)
|
||||
LAVA_ENABLED: bool = False
|
||||
LAVA_BASE_URL: str = 'https://gate.lava.ru'
|
||||
LAVA_SHOP_ID: str | None = None # UUID проекта
|
||||
LAVA_SECRET_KEY: str | None = None # secret_key — для подписи запросов
|
||||
LAVA_WEBHOOK_SECRET: str | None = None # secret_key_2 — для проверки подписи webhook
|
||||
LAVA_DISPLAY_NAME: str = 'Lava'
|
||||
LAVA_CURRENCY: str = 'RUB'
|
||||
LAVA_MIN_AMOUNT_KOPEKS: int = 10000 # 100₽
|
||||
LAVA_MAX_AMOUNT_KOPEKS: int = 10000000 # 100 000₽
|
||||
LAVA_WEBHOOK_PATH: str = '/lava-webhook'
|
||||
LAVA_RETURN_URL: str | None = None
|
||||
LAVA_PAYMENT_LIFETIME_MINUTES: int = 60 # макс 7200 минут (5 дней)
|
||||
# Sub-методы Lava (фильтр через includeService/excludeService на стороне Lava)
|
||||
LAVA_CARD_ENABLED: bool = False
|
||||
LAVA_CARD_DISPLAY_NAME: str = 'Карта (Lava)'
|
||||
LAVA_SBP_ENABLED: bool = False
|
||||
LAVA_SBP_DISPLAY_NAME: str = 'СБП (Lava)'
|
||||
|
||||
# Etoplatezhi (paymentpage.etoplatezhi.ru)
|
||||
ETOPLATEZHI_ENABLED: bool = False
|
||||
ETOPLATEZHI_PROJECT_ID: int | None = None
|
||||
@@ -2343,6 +2362,41 @@ class Settings(BaseSettings):
|
||||
def get_donut_sbp_qr_display_name_html(self) -> str:
|
||||
return html.escape(self.get_donut_sbp_qr_display_name())
|
||||
|
||||
def is_lava_enabled(self) -> bool:
|
||||
return (
|
||||
self.LAVA_ENABLED
|
||||
and self.LAVA_SHOP_ID is not None
|
||||
and self.LAVA_SECRET_KEY is not None
|
||||
and self.LAVA_WEBHOOK_SECRET is not None
|
||||
)
|
||||
|
||||
def get_lava_display_name(self) -> str:
|
||||
name = (self.LAVA_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'Lava'
|
||||
|
||||
def get_lava_display_name_html(self) -> str:
|
||||
return html.escape(self.get_lava_display_name())
|
||||
|
||||
def is_lava_card_enabled(self) -> bool:
|
||||
return self.LAVA_CARD_ENABLED and self.is_lava_enabled()
|
||||
|
||||
def get_lava_card_display_name(self) -> str:
|
||||
name = (self.LAVA_CARD_DISPLAY_NAME or '').strip()
|
||||
return name or 'Карта (Lava)'
|
||||
|
||||
def get_lava_card_display_name_html(self) -> str:
|
||||
return html.escape(self.get_lava_card_display_name())
|
||||
|
||||
def is_lava_sbp_enabled(self) -> bool:
|
||||
return self.LAVA_SBP_ENABLED and self.is_lava_enabled()
|
||||
|
||||
def get_lava_sbp_display_name(self) -> str:
|
||||
name = (self.LAVA_SBP_DISPLAY_NAME or '').strip()
|
||||
return name or 'СБП (Lava)'
|
||||
|
||||
def get_lava_sbp_display_name_html(self) -> str:
|
||||
return html.escape(self.get_lava_sbp_display_name())
|
||||
|
||||
def is_etoplatezhi_enabled(self) -> bool:
|
||||
return (
|
||||
self.ETOPLATEZHI_ENABLED
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"""CRUD операции для платежей Lava (Lava Business)."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import LavaPayment
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_lava_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,
|
||||
lava_invoice_id: str | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
metadata_json: dict | None = None,
|
||||
) -> LavaPayment:
|
||||
"""Создаёт запись о платеже Lava."""
|
||||
payment = LavaPayment(
|
||||
user_id=user_id,
|
||||
order_id=order_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
payment_method=payment_method,
|
||||
lava_invoice_id=lava_invoice_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('Создан платеж Lava', order_id=order_id, user_id=user_id)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_lava_payment_by_order_id(db: AsyncSession, order_id: str) -> LavaPayment | None:
|
||||
"""Получает платёж по нашему orderId."""
|
||||
result = await db.execute(select(LavaPayment).where(LavaPayment.order_id == order_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_lava_payment_by_invoice_id(db: AsyncSession, lava_invoice_id: str) -> LavaPayment | None:
|
||||
"""Получает платёж по invoice_id, выданному Lava."""
|
||||
result = await db.execute(
|
||||
select(LavaPayment).where(LavaPayment.lava_invoice_id == lava_invoice_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_lava_payment_by_id(db: AsyncSession, payment_id: int) -> LavaPayment | None:
|
||||
"""Получает платёж по локальному ID."""
|
||||
result = await db.execute(select(LavaPayment).where(LavaPayment.id == payment_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_lava_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> LavaPayment | None:
|
||||
"""Получает платёж с FOR UPDATE-блокировкой."""
|
||||
result = await db.execute(
|
||||
select(LavaPayment)
|
||||
.where(LavaPayment.id == payment_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_lava_payment_status(
|
||||
db: AsyncSession,
|
||||
payment: LavaPayment,
|
||||
*,
|
||||
status: str,
|
||||
is_paid: bool | None = None,
|
||||
lava_invoice_id: str | None = None,
|
||||
payment_method: str | None = None,
|
||||
callback_payload: dict | None = None,
|
||||
transaction_id: int | None = None,
|
||||
) -> LavaPayment:
|
||||
"""Обновляет статус платежа."""
|
||||
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 lava_invoice_id is not None:
|
||||
payment.lava_invoice_id = lava_invoice_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(
|
||||
'Обновлён статус платежа Lava',
|
||||
order_id=payment.order_id,
|
||||
status=status,
|
||||
is_paid=payment.is_paid,
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
async def get_pending_lava_payments(db: AsyncSession, user_id: int) -> list[LavaPayment]:
|
||||
"""Возвращает незавершённые платежи пользователя."""
|
||||
result = await db.execute(
|
||||
select(LavaPayment).where(
|
||||
LavaPayment.user_id == user_id,
|
||||
LavaPayment.status == 'pending',
|
||||
LavaPayment.is_paid == False,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_expired_pending_lava_payments(db: AsyncSession) -> list[LavaPayment]:
|
||||
"""Возвращает просроченные платежи в статусе pending."""
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(LavaPayment).where(
|
||||
LavaPayment.status == 'pending',
|
||||
LavaPayment.is_paid == False,
|
||||
LavaPayment.expires_at < now,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def link_lava_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
payment: LavaPayment,
|
||||
transaction_id: int,
|
||||
) -> LavaPayment:
|
||||
"""Связывает платёж с транзакцией."""
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
@@ -171,6 +171,7 @@ class PaymentMethod(Enum):
|
||||
ANTILOPAY = 'antilopay'
|
||||
JUPITER = 'jupiter'
|
||||
DONUT = 'donut'
|
||||
LAVA = 'lava'
|
||||
MANUAL = 'manual'
|
||||
BALANCE = 'balance'
|
||||
|
||||
@@ -1418,6 +1419,68 @@ class DonutPayment(Base):
|
||||
return f'<DonutPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
||||
|
||||
|
||||
class LavaPayment(Base):
|
||||
"""Платежи через Lava Business (gate.lava.ru)."""
|
||||
|
||||
__tablename__ = 'lava_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) # Наш orderId
|
||||
lava_invoice_id = Column(String(128), unique=True, nullable=True, index=True) # invoice_id (UUID) от Lava
|
||||
|
||||
# Суммы
|
||||
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) # 'card', 'sbp' и т.д.
|
||||
|
||||
# Метаданные
|
||||
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='lava_payments')
|
||||
transaction = relationship('Transaction', backref='lava_payment')
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
@property
|
||||
def is_pending(self) -> bool:
|
||||
return self.status in ('pending', 'created', 'processing')
|
||||
|
||||
@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', 'cancel', 'cancelled', 'amount_mismatch', 'error']
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return f'<LavaPayment(id={self.id}, order_id={self.order_id}, amount={self.amount_rubles}₽, status={self.status})>'
|
||||
|
||||
|
||||
class PromoGroup(Base):
|
||||
__tablename__ = 'promo_groups'
|
||||
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
"""Handler for Lava balance top-up (Lava Business, gate.lava.ru)."""
|
||||
|
||||
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__)
|
||||
|
||||
|
||||
LAVA_PAYMENT_METHODS = {'lava', 'lava_card', 'lava_sbp'}
|
||||
|
||||
LAVA_SERVICE_MAP: dict[str, str | None] = {
|
||||
'lava': None,
|
||||
'lava_card': 'card',
|
||||
'lava_sbp': 'sbp',
|
||||
}
|
||||
|
||||
|
||||
def _extract_service_type(payment_method: str) -> str | None:
|
||||
return LAVA_SERVICE_MAP.get(payment_method)
|
||||
|
||||
|
||||
def _check_topup_restriction(db_user: User, texts) -> InlineKeyboardMarkup | 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)
|
||||
|
||||
|
||||
def _get_display_name(payment_method: str) -> str:
|
||||
if payment_method == 'lava_card':
|
||||
return settings.get_lava_card_display_name()
|
||||
if payment_method == 'lava_sbp':
|
||||
return settings.get_lava_sbp_display_name()
|
||||
return settings.get_lava_display_name()
|
||||
|
||||
|
||||
async def _create_lava_payment_and_respond(
|
||||
message_or_callback,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
edit_message: bool = False,
|
||||
payment_method_type: str | None = None,
|
||||
display_name: str | None = None,
|
||||
):
|
||||
"""Создаёт инвойс Lava и отправляет ссылку пользователю."""
|
||||
texts = get_texts(db_user.language)
|
||||
amount_rub = amount_kopeks / 100
|
||||
|
||||
payment_service = PaymentService()
|
||||
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
|
||||
service_name=settings.PAYMENT_SERVICE_NAME,
|
||||
description='Пополнение баланса',
|
||||
)
|
||||
|
||||
result = await payment_service.create_lava_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')
|
||||
name = display_name or settings.get_lava_display_name()
|
||||
|
||||
# Без URL mixin вернул бы None ещё до этого блока; здесь URL гарантирован.
|
||||
pay_button_text = texts.t('PAY_BUTTON', '\U0001f4b3 Оплатить {amount}₽').format(
|
||||
amount=f'{amount_rub:.0f}',
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text=pay_button_text, url=payment_url)],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '◀️ Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
response_text = texts.t(
|
||||
'LAVA_PAYMENT_CREATED',
|
||||
'\U0001f4b3 <b>Оплата через {name}</b>\n\n'
|
||||
'Сумма: <b>{amount}₽</b>\n\n'
|
||||
'Нажмите кнопку ниже для перехода к оплате.\n'
|
||||
'После подтверждения платежа баланс будет пополнен автоматически.',
|
||||
).format(name=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('Lava payment created', telegram_id=db_user.telegram_id, amount_rub=amount_rub)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_lava_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""Обрабатывает сумму для Lava."""
|
||||
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
|
||||
|
||||
min_amount = settings.LAVA_MIN_AMOUNT_KOPEKS
|
||||
max_amount = settings.LAVA_MAX_AMOUNT_KOPEKS
|
||||
|
||||
if amount_kopeks < min_amount:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PAYMENT_AMOUNT_TOO_LOW',
|
||||
'Минимальная сумма пополнения: {min_amount}₽',
|
||||
).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}₽',
|
||||
).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', 'lava')
|
||||
payment_method_type = _extract_service_type(payment_method)
|
||||
display_name = _get_display_name(payment_method)
|
||||
|
||||
await state.clear()
|
||||
|
||||
await _create_lava_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,
|
||||
display_name=display_name,
|
||||
)
|
||||
|
||||
|
||||
async def _start_lava_topup_impl(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
payment_method: str,
|
||||
):
|
||||
"""Стартует FSM ввода суммы для Lava."""
|
||||
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.LAVA_MIN_AMOUNT_KOPEKS // 100
|
||||
max_amount = settings.LAVA_MAX_AMOUNT_KOPEKS // 100
|
||||
|
||||
display_name = _get_display_name(payment_method)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_BUTTON', '◀️ Назад'),
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
'LAVA_ENTER_AMOUNT',
|
||||
'\U0001f4b3 <b>Пополнение через {name}</b>\n\n'
|
||||
'Введите сумму пополнения в рублях.\n\n'
|
||||
'Минимум: {min_amount}₽\n'
|
||||
'Максимум: {max_amount}₽',
|
||||
).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_lava_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_lava_topup_impl(callback, db_user, state, 'lava')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_lava_card_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_lava_topup_impl(callback, db_user, state, 'lava_card')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_lava_sbp_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_lava_topup_impl(callback, db_user, state, 'lava_sbp')
|
||||
@@ -205,6 +205,13 @@ async def route_payment_by_method(
|
||||
await process_donut_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method in ('lava', 'lava_card', 'lava_sbp'):
|
||||
from .lava import process_lava_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_lava_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method == 'riopay':
|
||||
from .riopay import process_riopay_payment_amount
|
||||
|
||||
@@ -837,6 +844,12 @@ def register_balance_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(start_donut_sbp_topup, F.data == 'topup_donut_sbp')
|
||||
dp.callback_query.register(start_donut_sbp_qr_topup, F.data == 'topup_donut_sbp_qr')
|
||||
|
||||
from .lava import start_lava_card_topup, start_lava_sbp_topup, start_lava_topup
|
||||
|
||||
dp.callback_query.register(start_lava_topup, F.data == 'topup_lava')
|
||||
dp.callback_query.register(start_lava_card_topup, F.data == 'topup_lava_card')
|
||||
dp.callback_query.register(start_lava_sbp_topup, F.data == 'topup_lava_sbp')
|
||||
|
||||
from .mulenpay import check_mulenpay_payment_status
|
||||
|
||||
dp.callback_query.register(check_mulenpay_payment_status, F.data.startswith('check_mulenpay_'))
|
||||
|
||||
@@ -2060,6 +2060,46 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_lava_card_enabled():
|
||||
lava_card_name = settings.get_lava_card_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_LAVA_CARD', f'💳 {lava_card_name}'),
|
||||
callback_data=_build_callback('lava_card'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_lava_sbp_enabled():
|
||||
lava_sbp_name = settings.get_lava_sbp_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_LAVA_SBP', f'📱 {lava_sbp_name}'),
|
||||
callback_data=_build_callback('lava_sbp'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if (
|
||||
settings.is_lava_enabled()
|
||||
and not settings.is_lava_card_enabled()
|
||||
and not settings.is_lava_sbp_enabled()
|
||||
):
|
||||
lava_name = settings.get_lava_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_LAVA', f'🌋 {lava_name}'),
|
||||
callback_data=_build_callback('lava'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_support_topup_enabled():
|
||||
keyboard.append(
|
||||
[
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Сервис для работы с API Lava Business (gate.lava.ru)."""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
import structlog
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class LavaAPIError(Exception):
|
||||
"""Ошибка API Lava."""
|
||||
|
||||
def __init__(self, status_code: int, message: str, code: str | int | None = None) -> None:
|
||||
self.status_code = status_code
|
||||
self.message = message
|
||||
self.api_code = code
|
||||
super().__init__(f'Lava API error ({status_code}): {message}')
|
||||
|
||||
|
||||
class LavaService:
|
||||
"""Клиент для Lava Business API (gate.lava.ru).
|
||||
|
||||
Подпись запросов: HMAC-SHA256(json_body, secret_key) → hex.
|
||||
Передаётся в заголовке ``Signature``.
|
||||
Ключи `secret_key` (запросы) и `secret_key_2` (webhook) выдаются мерчанту в личном кабинете.
|
||||
Каноническая строка для подписи — JSON в том же порядке, в котором отправляется в теле.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._session: aiohttp.ClientSession | None = None
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
return (settings.LAVA_BASE_URL or 'https://gate.lava.ru').rstrip('/')
|
||||
|
||||
@property
|
||||
def shop_id(self) -> str:
|
||||
return settings.LAVA_SHOP_ID or ''
|
||||
|
||||
@property
|
||||
def secret_key(self) -> str:
|
||||
return settings.LAVA_SECRET_KEY or ''
|
||||
|
||||
@property
|
||||
def webhook_secret(self) -> str:
|
||||
# secret_key_2 — для проверки подписи webhook'а
|
||||
return settings.LAVA_WEBHOOK_SECRET or ''
|
||||
|
||||
async def _get_session(self) -> aiohttp.ClientSession:
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
)
|
||||
return self._session
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
self._session = None
|
||||
|
||||
@staticmethod
|
||||
def _serialize(payload: dict[str, Any]) -> str:
|
||||
"""Сериализация JSON для подписи и тела запроса.
|
||||
|
||||
Lava подписывает байт-в-байт ту же строку, что и отправляется в теле, поэтому
|
||||
порядок ключей определяется порядком вставки в payload (Python ≥3.7 dict сохраняет
|
||||
порядок). Используем компактный сепаратор и UTF-8 без экранирования юникода.
|
||||
"""
|
||||
return json.dumps(payload, separators=(',', ':'), ensure_ascii=False)
|
||||
|
||||
def _hmac_hex(self, message: str | bytes, key: str | None = None) -> str:
|
||||
secret = (key if key is not None else self.secret_key) or ''
|
||||
msg_bytes = message if isinstance(message, (bytes, bytearray)) else message.encode('utf-8')
|
||||
return hmac.new(
|
||||
secret.encode('utf-8'),
|
||||
msg=msg_bytes,
|
||||
digestmod=hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
def _build_headers(self, body: str) -> dict[str, str]:
|
||||
return {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Signature': self._hmac_hex(body),
|
||||
}
|
||||
|
||||
async def _post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
url = f'{self.base_url}/{path.lstrip("/")}'
|
||||
body = self._serialize(payload)
|
||||
try:
|
||||
session = await self._get_session()
|
||||
async with session.post(url, data=body, headers=self._build_headers(body)) as response:
|
||||
try:
|
||||
data = await response.json(content_type=None)
|
||||
except Exception:
|
||||
text = await response.text()
|
||||
data = {'_raw': text}
|
||||
if not isinstance(data, dict):
|
||||
data = {'_raw': data}
|
||||
if response.status >= 400:
|
||||
error_msg = (
|
||||
data.get('error')
|
||||
or (data.get('data') or {}).get('error')
|
||||
or data.get('message')
|
||||
or 'Lava API HTTP error'
|
||||
)
|
||||
logger.warning(
|
||||
'Lava API HTTP error',
|
||||
url=url,
|
||||
status=response.status,
|
||||
error_msg=str(error_msg),
|
||||
code=data.get('code'),
|
||||
)
|
||||
raise LavaAPIError(response.status, str(error_msg), data.get('code'))
|
||||
return data
|
||||
except aiohttp.ClientError as error:
|
||||
logger.exception('Lava API connection error', url=url, error=error)
|
||||
raise
|
||||
|
||||
async def create_invoice(
|
||||
self,
|
||||
*,
|
||||
amount_rubles: float,
|
||||
order_id: str,
|
||||
success_url: str | None = None,
|
||||
fail_url: str | None = None,
|
||||
hook_url: str | None = None,
|
||||
expire_minutes: int | None = None,
|
||||
comment: str | None = None,
|
||||
custom_fields: str | None = None,
|
||||
include_service: list[str] | None = None,
|
||||
exclude_service: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Создаёт инвойс через POST /api/v2/invoice/create.
|
||||
|
||||
Сумма передаётся в рублях с двумя знаками после запятой.
|
||||
``orderId`` — наш уникальный идентификатор платежа.
|
||||
"""
|
||||
# Порядок полей важен (этим же порядком сериализуется и подписывается)
|
||||
payload: dict[str, Any] = {
|
||||
'sum': round(float(amount_rubles), 2),
|
||||
'orderId': str(order_id),
|
||||
'shopId': self.shop_id,
|
||||
}
|
||||
if hook_url:
|
||||
payload['hookUrl'] = hook_url[:500]
|
||||
if success_url:
|
||||
payload['successUrl'] = success_url[:500]
|
||||
if fail_url:
|
||||
payload['failUrl'] = fail_url[:500]
|
||||
if expire_minutes is not None:
|
||||
# Lava лимит: 1..7200 минут (5 дней)
|
||||
payload['expire'] = max(1, min(7200, int(expire_minutes)))
|
||||
if comment:
|
||||
payload['comment'] = comment[:255]
|
||||
if custom_fields:
|
||||
payload['customFields'] = custom_fields[:500]
|
||||
if include_service:
|
||||
payload['includeService'] = list(include_service)
|
||||
if exclude_service:
|
||||
payload['excludeService'] = list(exclude_service)
|
||||
|
||||
logger.info('Lava API invoice/create', order_id=order_id, sum=payload['sum'])
|
||||
data = await self._post('/api/v2/invoice/create', payload)
|
||||
|
||||
# Lava возвращает {"status": "success", "data": {...}} или {"status": "error", "error": "..."}
|
||||
if isinstance(data.get('status'), str) and data['status'].lower() == 'error':
|
||||
raise LavaAPIError(200, str(data.get('error') or data.get('message') or 'unknown'))
|
||||
|
||||
return data
|
||||
|
||||
async def get_invoice_status(
|
||||
self,
|
||||
*,
|
||||
order_id: str | None = None,
|
||||
invoice_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""POST /api/v2/invoice/status — статус инвойса по orderId или invoiceId."""
|
||||
if not order_id and not invoice_id:
|
||||
raise ValueError('Lava status: order_id or invoice_id required')
|
||||
|
||||
payload: dict[str, Any] = {'shopId': self.shop_id}
|
||||
if invoice_id:
|
||||
payload['invoiceId'] = str(invoice_id)
|
||||
if order_id:
|
||||
payload['orderId'] = str(order_id)
|
||||
|
||||
logger.info('Lava API invoice/status', order_id=order_id, invoice_id=invoice_id)
|
||||
return await self._post('/api/v2/invoice/status', payload)
|
||||
|
||||
async def get_services(self) -> dict[str, Any]:
|
||||
"""POST /api/v2/invoice/services — доступные методы оплаты для shopId."""
|
||||
payload: dict[str, Any] = {'shopId': self.shop_id}
|
||||
return await self._post('/api/v2/invoice/services', payload)
|
||||
|
||||
def verify_webhook_signature(self, raw_body: bytes, received_signature: str) -> bool:
|
||||
"""Верификация подписи webhook (заголовок ``Authorization``).
|
||||
|
||||
Lava Business webhook подписан HMAC-SHA256 от raw JSON body ключом ``secret_key_2``.
|
||||
"""
|
||||
try:
|
||||
if not received_signature:
|
||||
logger.warning('Lava webhook: отсутствует Authorization header')
|
||||
return False
|
||||
if not self.webhook_secret:
|
||||
logger.error('Lava webhook: LAVA_WEBHOOK_SECRET не настроен')
|
||||
return False
|
||||
|
||||
# HMAC берётся напрямую от raw bytes — без decode/encode round-trip,
|
||||
# чтобы не терять байты при некорректной кодировке payload.
|
||||
expected = self._hmac_hex(raw_body, key=self.webhook_secret)
|
||||
received = received_signature.strip()
|
||||
|
||||
if not hmac.compare_digest(expected.lower(), received.lower()):
|
||||
logger.warning(
|
||||
'Lava webhook: invalid signature',
|
||||
received_prefix=received[:8],
|
||||
)
|
||||
return False
|
||||
return True
|
||||
except Exception as error:
|
||||
logger.error('Lava webhook verify error', error=error)
|
||||
return False
|
||||
|
||||
|
||||
# Singleton instance
|
||||
lava_service = LavaService()
|
||||
@@ -0,0 +1,594 @@
|
||||
"""Mixin для интеграции с Lava Business (gate.lava.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.lava_service import lava_service
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
|
||||
# Маппинг sub-method -> includeService для фильтрации методов на странице оплаты Lava
|
||||
LAVA_INCLUDE_SERVICE_MAP: dict[str | None, list[str] | None] = {
|
||||
None: None,
|
||||
'card': ['card'],
|
||||
'sbp': ['sbp'],
|
||||
}
|
||||
|
||||
|
||||
# Маппинг статусов Lava -> internal
|
||||
LAVA_STATUS_MAP: dict[str, tuple[str, bool]] = {
|
||||
'created': ('pending', False),
|
||||
'pending': ('pending', False),
|
||||
'processing': ('pending', False), # на случай промежуточного статуса
|
||||
'success': ('success', True),
|
||||
'cancel': ('cancelled', False),
|
||||
'cancelled': ('cancelled', False),
|
||||
'expired': ('expired', False),
|
||||
'error': ('error', False),
|
||||
'failed': ('failed', False),
|
||||
}
|
||||
|
||||
|
||||
class LavaPaymentMixin:
|
||||
"""Mixin для работы с платежами Lava Business."""
|
||||
|
||||
async def create_lava_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:
|
||||
"""Создаёт инвойс Lava."""
|
||||
if not settings.is_lava_enabled():
|
||||
logger.error('Lava не настроен')
|
||||
return None
|
||||
|
||||
if amount_kopeks < settings.LAVA_MIN_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'Lava: сумма меньше минимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
LAVA_MIN_AMOUNT_KOPEKS=settings.LAVA_MIN_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
if amount_kopeks > settings.LAVA_MAX_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
'Lava: сумма больше максимальной',
|
||||
amount_kopeks=amount_kopeks,
|
||||
LAVA_MAX_AMOUNT_KOPEKS=settings.LAVA_MAX_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
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'
|
||||
|
||||
# 32 hex char (128 бит) суффикс — order_id уникален даже при публичном tg_id
|
||||
order_id = f'lava{tg_id}_{uuid.uuid4().hex}'
|
||||
amount_rubles = amount_kopeks / 100
|
||||
currency = settings.LAVA_CURRENCY
|
||||
|
||||
method_key = (payment_method_type or '').lower() or None
|
||||
include_service = LAVA_INCLUDE_SERVICE_MAP.get(method_key)
|
||||
|
||||
metadata = {
|
||||
'user_id': user_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'description': description,
|
||||
'language': language,
|
||||
'type': 'balance_topup',
|
||||
'payment_method_type': method_key,
|
||||
'email': email,
|
||||
}
|
||||
|
||||
try:
|
||||
hook_url = self._build_lava_hook_url()
|
||||
if not hook_url:
|
||||
logger.warning(
|
||||
'Lava: hook_url не сконфигурирован — '
|
||||
'платёж создаётся, но автоматическое подтверждение через webhook невозможно. '
|
||||
'Установите WEBHOOK_URL / WEB_API_BASE_URL / CABINET_URL.'
|
||||
)
|
||||
actual_return_url = return_url or settings.LAVA_RETURN_URL
|
||||
|
||||
api_result = await lava_service.create_invoice(
|
||||
amount_rubles=amount_rubles,
|
||||
order_id=order_id,
|
||||
hook_url=hook_url,
|
||||
success_url=actual_return_url,
|
||||
fail_url=actual_return_url,
|
||||
expire_minutes=settings.LAVA_PAYMENT_LIFETIME_MINUTES,
|
||||
comment=(description or '')[:255] or None,
|
||||
custom_fields=str(user_id) if user_id is not None else None,
|
||||
include_service=include_service,
|
||||
)
|
||||
|
||||
data = (api_result.get('data') or api_result) if isinstance(api_result, dict) else {}
|
||||
lava_invoice_id = data.get('id') or data.get('invoice_id')
|
||||
payment_url = data.get('url') or data.get('payment_url')
|
||||
expired_str = data.get('expired')
|
||||
|
||||
if not payment_url:
|
||||
# Без URL у пользователя нет способа оплатить — это аномалия Lava API.
|
||||
# Row не сохраняем, чтобы не плодить «зависшие» pending-инвойсы без реквизитов.
|
||||
logger.error(
|
||||
'Lava: ответ API без payment URL, инвойс не создан',
|
||||
order_id=order_id,
|
||||
lava_invoice_id=lava_invoice_id,
|
||||
response_keys=list(data.keys()) if isinstance(data, dict) else None,
|
||||
)
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
'Lava: получен ответ API',
|
||||
order_id=order_id,
|
||||
lava_invoice_id=lava_invoice_id,
|
||||
payment_url=payment_url,
|
||||
)
|
||||
|
||||
lifetime = settings.LAVA_PAYMENT_LIFETIME_MINUTES
|
||||
expires_at = self._parse_lava_expired(expired_str) or (
|
||||
datetime.now(UTC) + timedelta(minutes=lifetime)
|
||||
)
|
||||
|
||||
lava_crud = import_module('app.database.crud.lava')
|
||||
local_payment = await lava_crud.create_lava_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=method_key,
|
||||
lava_invoice_id=str(lava_invoice_id) if lava_invoice_id else None,
|
||||
expires_at=expires_at,
|
||||
metadata_json=metadata,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Lava: создан платеж',
|
||||
order_id=order_id,
|
||||
user_id=user_id,
|
||||
amount_rubles=amount_rubles,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
return {
|
||||
'order_id': order_id,
|
||||
'amount_kopeks': amount_kopeks,
|
||||
'amount_rubles': amount_rubles,
|
||||
'currency': currency,
|
||||
'payment_url': payment_url,
|
||||
'payment_id': str(lava_invoice_id) if lava_invoice_id else None,
|
||||
'expires_at': expires_at.isoformat(),
|
||||
'local_payment_id': local_payment.id,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('Lava: ошибка создания платежа', error=e)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _parse_lava_expired(value: Any) -> datetime | None:
|
||||
"""Парсит поле ``expired`` из ответа Lava.
|
||||
|
||||
Принимаются только TZ-aware строки (ISO с offset/Z) или unix timestamp.
|
||||
Naive-строки игнорируются — TZ Lava в спеке не задокументирована,
|
||||
а угадывание UTC может сместить срок жизни инвойса на несколько часов.
|
||||
Если парсинг не удался — caller использует fallback ``now + lifetime``.
|
||||
"""
|
||||
if value is None or value == '':
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
try:
|
||||
return datetime.fromtimestamp(float(value), tz=UTC)
|
||||
except (ValueError, OSError):
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
from dateutil.parser import isoparse # type: ignore[import-not-found]
|
||||
|
||||
parsed = isoparse(value)
|
||||
if parsed.tzinfo is None:
|
||||
return None # без TZ доверять не можем
|
||||
return parsed
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _build_lava_hook_url() -> str | None:
|
||||
"""Собирает абсолютный URL вебхука для Lava."""
|
||||
webhook_path = settings.LAVA_WEBHOOK_PATH or '/lava-webhook'
|
||||
base = (
|
||||
getattr(settings, 'WEBHOOK_URL', None)
|
||||
or getattr(settings, 'WEB_API_BASE_URL', None)
|
||||
or getattr(settings, 'CABINET_URL', None)
|
||||
)
|
||||
if not base:
|
||||
return None
|
||||
suffix = webhook_path if webhook_path.startswith('/') else f'/{webhook_path}'
|
||||
return f'{base.rstrip("/")}{suffix}'
|
||||
|
||||
async def process_lava_callback(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payload: dict[str, Any],
|
||||
) -> bool:
|
||||
"""Обрабатывает webhook от Lava (подпись уже проверена в webserver)."""
|
||||
try:
|
||||
lava_invoice_id = payload.get('invoice_id')
|
||||
our_order_id = payload.get('order_id')
|
||||
lava_status = (payload.get('status') or '').strip().lower()
|
||||
pay_service = payload.get('pay_service')
|
||||
|
||||
if not our_order_id or not lava_status:
|
||||
logger.warning('Lava webhook: отсутствуют обязательные поля')
|
||||
return False
|
||||
|
||||
lava_crud = import_module('app.database.crud.lava')
|
||||
payment = await lava_crud.get_lava_payment_by_order_id(db, our_order_id)
|
||||
if not payment:
|
||||
# Fallback по invoice_id, но строго проверяем совпадение order_id
|
||||
if lava_invoice_id:
|
||||
payment = await lava_crud.get_lava_payment_by_invoice_id(db, str(lava_invoice_id))
|
||||
if payment and payment.order_id != our_order_id:
|
||||
logger.error(
|
||||
'Lava webhook: order_id mismatch',
|
||||
webhook_order_id=our_order_id,
|
||||
record_order_id=payment.order_id,
|
||||
invoice_id=lava_invoice_id,
|
||||
)
|
||||
return False
|
||||
if not payment:
|
||||
logger.warning('Lava webhook: платеж не найден', order_id=our_order_id)
|
||||
return False
|
||||
|
||||
locked = await lava_crud.get_lava_payment_by_id_for_update(db, payment.id)
|
||||
if not locked:
|
||||
logger.error('Lava: не удалось заблокировать платёж', payment_id=payment.id)
|
||||
return False
|
||||
payment = locked
|
||||
|
||||
if payment.is_paid:
|
||||
logger.info('Lava webhook: платеж уже обработан', order_id=payment.order_id)
|
||||
return True
|
||||
|
||||
# Терминальные неуспешные статусы — стики, защита от повторного успеха
|
||||
if payment.status in {'amount_mismatch', 'cancelled', 'cancel', 'error', 'expired', 'failed'}:
|
||||
# Если внезапно пришёл success после терминальной неудачи — это сигнал
|
||||
# подделки или ошибки на стороне Lava, эскалируем.
|
||||
if lava_status == 'success':
|
||||
logger.error(
|
||||
'Lava webhook: success на терминально-неуспешном платеже, игнорируется',
|
||||
order_id=payment.order_id,
|
||||
current_status=payment.status,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
'Lava webhook: платёж в терминальном неуспешном статусе, игнорируется',
|
||||
order_id=payment.order_id,
|
||||
current_status=payment.status,
|
||||
incoming_status=lava_status,
|
||||
)
|
||||
return True
|
||||
|
||||
if lava_status not in LAVA_STATUS_MAP:
|
||||
logger.warning(
|
||||
'Lava webhook: неизвестный статус, обрабатываем как pending',
|
||||
order_id=payment.order_id,
|
||||
incoming_status=lava_status,
|
||||
)
|
||||
internal_status, is_paid = LAVA_STATUS_MAP.get(lava_status, ('pending', False))
|
||||
|
||||
callback_payload = {
|
||||
'lava_invoice_id': lava_invoice_id,
|
||||
'status': lava_status,
|
||||
'amount': payload.get('amount'),
|
||||
'credited': payload.get('credited'),
|
||||
'pay_service': pay_service,
|
||||
'pay_time': payload.get('pay_time'),
|
||||
'payer_details': payload.get('payer_details'),
|
||||
'custom_fields': payload.get('custom_fields'),
|
||||
}
|
||||
|
||||
# Сверяем сумму ДО зачисления
|
||||
if is_paid:
|
||||
# Lava webhook содержит amount (сумма счёта в рублях, float).
|
||||
# Сверяем с тем, что мы отправляли на создание.
|
||||
received_amount = payload.get('amount')
|
||||
if received_amount is not None:
|
||||
try:
|
||||
received_kopeks = round(float(received_amount) * 100)
|
||||
except (TypeError, ValueError):
|
||||
received_kopeks = None
|
||||
if received_kopeks is not None and abs(received_kopeks - payment.amount_kopeks) > 1:
|
||||
logger.error(
|
||||
'Lava amount mismatch',
|
||||
expected_kopeks=payment.amount_kopeks,
|
||||
received_kopeks=received_kopeks,
|
||||
order_id=payment.order_id,
|
||||
)
|
||||
await lava_crud.update_lava_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status='amount_mismatch',
|
||||
is_paid=False,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
return False
|
||||
|
||||
if is_paid:
|
||||
payment.status = internal_status
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
if lava_invoice_id and not payment.lava_invoice_id:
|
||||
payment.lava_invoice_id = str(lava_invoice_id)
|
||||
# Сохраняем pay_service в metadata, не перезаписывая user-выбранный payment_method
|
||||
if pay_service:
|
||||
metadata_now = dict(getattr(payment, 'metadata_json', {}) or {})
|
||||
metadata_now['actual_pay_service'] = str(pay_service).lower()
|
||||
payment.metadata_json = metadata_now
|
||||
payment.callback_payload = callback_payload
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
return await self._finalize_lava_payment(db, payment, trigger='webhook')
|
||||
|
||||
payment = await lava_crud.update_lava_payment_status(
|
||||
db=db,
|
||||
payment=payment,
|
||||
status=internal_status,
|
||||
is_paid=False,
|
||||
callback_payload=callback_payload,
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.exception('Lava webhook: ошибка обработки', error=e)
|
||||
return False
|
||||
|
||||
async def _finalize_lava_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payment: Any,
|
||||
*,
|
||||
trigger: str,
|
||||
) -> bool:
|
||||
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления.
|
||||
|
||||
FOR UPDATE-lock уже взят вызывающим.
|
||||
"""
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
lava_crud = import_module('app.database.crud.lava')
|
||||
|
||||
if payment.transaction_id:
|
||||
logger.info(
|
||||
'Lava платеж уже связан с транзакцией',
|
||||
order_id=payment.order_id,
|
||||
transaction_id=payment.transaction_id,
|
||||
trigger=trigger,
|
||||
)
|
||||
return True
|
||||
|
||||
metadata = dict(getattr(payment, 'metadata_json', {}) or {})
|
||||
|
||||
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='lava',
|
||||
)
|
||||
if guest_result is not None:
|
||||
return True
|
||||
|
||||
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('Пользователь не найден для Lava', 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.LAVA,
|
||||
)
|
||||
|
||||
display_name = settings.get_lava_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.LAVA,
|
||||
external_id=transaction_external_id,
|
||||
is_completed=True,
|
||||
created_at=getattr(payment, 'created_at', None),
|
||||
commit=False,
|
||||
)
|
||||
created_transaction = True
|
||||
|
||||
await lava_crud.link_lava_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('Lava платеж уже зачислил баланс ранее', order_id=payment.order_id)
|
||||
return True
|
||||
|
||||
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)
|
||||
|
||||
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.LAVA,
|
||||
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('Ошибка обработки реферального пополнения Lava', 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('Ошибка отправки админ уведомления Lava', 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,
|
||||
(
|
||||
'✅ <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('Ошибка отправки уведомления пользователю Lava', 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(
|
||||
'Обработан Lava платеж',
|
||||
order_id=payment.order_id,
|
||||
user_id=payment.user_id,
|
||||
trigger=trigger,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
async def check_lava_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
order_id: str | None = None,
|
||||
invoice_id: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Запрос статуса инвойса через API Lava."""
|
||||
try:
|
||||
return await lava_service.get_invoice_status(order_id=order_id, invoice_id=invoice_id)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Lava: ошибка проверки статуса',
|
||||
order_id=order_id,
|
||||
invoice_id=invoice_id,
|
||||
error=e,
|
||||
)
|
||||
return None
|
||||
@@ -230,6 +230,16 @@ def _get_method_defaults() -> dict:
|
||||
{'id': 'sbp_qr', 'name': 'СБП QR'},
|
||||
],
|
||||
},
|
||||
'lava': {
|
||||
'default_display_name': settings.get_lava_display_name(),
|
||||
'is_configured': settings.is_lava_enabled(),
|
||||
'default_min': settings.LAVA_MIN_AMOUNT_KOPEKS,
|
||||
'default_max': settings.LAVA_MAX_AMOUNT_KOPEKS,
|
||||
'available_sub_options': [
|
||||
{'id': 'card', 'name': 'Карта'},
|
||||
{'id': 'sbp', 'name': 'СБП'},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -280,6 +290,7 @@ DEFAULT_METHOD_ORDER = [
|
||||
'antilopay',
|
||||
'jupiter',
|
||||
'donut',
|
||||
'lava',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ from app.services.payment.etoplatezhi import EtoplatezhiPaymentMixin
|
||||
from app.services.payment.freekassa import FreekassaPaymentMixin
|
||||
from app.services.payment.jupiter import JupiterPaymentMixin
|
||||
from app.services.payment.kassa_ai import KassaAiPaymentMixin
|
||||
from app.services.payment.lava import LavaPaymentMixin
|
||||
from app.services.payment.overpay import OverpayPaymentMixin
|
||||
from app.services.payment.paypear import PayPearPaymentMixin
|
||||
from app.services.payment.riopay import RioPayPaymentMixin
|
||||
@@ -626,6 +627,41 @@ async def link_donut_payment_to_transaction(*args, **kwargs):
|
||||
return await donut_crud.link_donut_payment_to_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
async def create_lava_payment(*args, **kwargs):
|
||||
lava_crud = import_module('app.database.crud.lava')
|
||||
return await lava_crud.create_lava_payment(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_lava_payment_by_order_id(*args, **kwargs):
|
||||
lava_crud = import_module('app.database.crud.lava')
|
||||
return await lava_crud.get_lava_payment_by_order_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_lava_payment_by_invoice_id(*args, **kwargs):
|
||||
lava_crud = import_module('app.database.crud.lava')
|
||||
return await lava_crud.get_lava_payment_by_invoice_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_lava_payment_by_id(*args, **kwargs):
|
||||
lava_crud = import_module('app.database.crud.lava')
|
||||
return await lava_crud.get_lava_payment_by_id(*args, **kwargs)
|
||||
|
||||
|
||||
async def get_lava_payment_by_id_for_update(*args, **kwargs):
|
||||
lava_crud = import_module('app.database.crud.lava')
|
||||
return await lava_crud.get_lava_payment_by_id_for_update(*args, **kwargs)
|
||||
|
||||
|
||||
async def update_lava_payment_status(*args, **kwargs):
|
||||
lava_crud = import_module('app.database.crud.lava')
|
||||
return await lava_crud.update_lava_payment_status(*args, **kwargs)
|
||||
|
||||
|
||||
async def link_lava_payment_to_transaction(*args, **kwargs):
|
||||
lava_crud = import_module('app.database.crud.lava')
|
||||
return await lava_crud.link_lava_payment_to_transaction(*args, **kwargs)
|
||||
|
||||
|
||||
# Mapping from model_name to getter function name for providers
|
||||
# where it differs from the standard get_{model_name}_payment_by_id pattern.
|
||||
_GETTER_OVERRIDES: dict[str, str] = {
|
||||
@@ -657,6 +693,7 @@ class PaymentService(
|
||||
AntilopayPaymentMixin,
|
||||
JupiterPaymentMixin,
|
||||
DonutPaymentMixin,
|
||||
LavaPaymentMixin,
|
||||
):
|
||||
"""Основной интерфейс платежей, делегирующий работу специализированным mixin-ам."""
|
||||
|
||||
@@ -1252,6 +1289,28 @@ class PaymentService(
|
||||
}
|
||||
return None
|
||||
|
||||
# --- Lava -------------------------------------------------------------
|
||||
if payment_method == 'lava':
|
||||
if not settings.is_lava_enabled():
|
||||
logger.warning('Lava is not enabled, cannot create guest payment')
|
||||
return None
|
||||
|
||||
result = await self.create_lava_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'], 'lava')
|
||||
return {
|
||||
'payment_url': result.get('payment_url'),
|
||||
'payment_id': result.get('order_id'),
|
||||
'provider': 'lava',
|
||||
}
|
||||
return None
|
||||
|
||||
# --- Telegram Stars ---------------------------------------------------
|
||||
if payment_method == 'telegram_stars':
|
||||
if not settings.TELEGRAM_STARS_ENABLED:
|
||||
|
||||
@@ -99,6 +99,7 @@ class BotConfigurationService:
|
||||
'ETOPLATEZHI': '💳 Etoplatezhi',
|
||||
'JUPITER': '🪐 Jupiter',
|
||||
'DONUT': '🍩 Donut',
|
||||
'LAVA': '🌋 Lava',
|
||||
'YOOKASSA': '🟣 YooKassa',
|
||||
'PLATEGA': '💳 {platega_name}',
|
||||
'TRIBUTE': '🎁 Tribute',
|
||||
@@ -167,6 +168,7 @@ class BotConfigurationService:
|
||||
'ETOPLATEZHI': 'Etoplatezhi: paymentpage.etoplatezhi.ru, оплата картой и через СБП.',
|
||||
'JUPITER': 'Jupiter (FPGate P2P v2.1): app.juppiter.tech, эквайринг СБП с HMAC-SHA256.',
|
||||
'DONUT': 'Donut P2P: gw.donut.business, P2P-оплата картой, СБП по телефону и QR.',
|
||||
'LAVA': 'Lava Business: gate.lava.ru, оплата картой и СБП с HMAC-SHA256 и подтверждением через webhook.',
|
||||
'PLATEGA': '{platega_name}: merchant ID, секрет, ссылки возврата и методы оплаты.',
|
||||
'MULENPAY': 'Платежи {mulenpay_name} и параметры магазина.',
|
||||
'PAL24': 'PAL24 / PayPalych подключения и лимиты.',
|
||||
@@ -385,6 +387,7 @@ class BotConfigurationService:
|
||||
'ETOPLATEZHI_': 'ETOPLATEZHI',
|
||||
'JUPITER_': 'JUPITER',
|
||||
'DONUT_': 'DONUT',
|
||||
'LAVA_': 'LAVA',
|
||||
'PLATEGA_': 'PLATEGA',
|
||||
'MULENPAY_': 'MULENPAY',
|
||||
'PAL24_': 'PAL24',
|
||||
|
||||
@@ -1610,6 +1610,54 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
|
||||
routes_registered = True
|
||||
|
||||
# Lava webhook (Lava Business)
|
||||
if settings.is_lava_enabled():
|
||||
|
||||
@router.get(settings.LAVA_WEBHOOK_PATH)
|
||||
async def lava_health() -> JSONResponse:
|
||||
return JSONResponse(
|
||||
{
|
||||
'status': 'ok',
|
||||
'service': 'lava_webhook',
|
||||
'enabled': settings.is_lava_enabled(),
|
||||
}
|
||||
)
|
||||
|
||||
@router.post(settings.LAVA_WEBHOOK_PATH)
|
||||
async def lava_webhook(request: Request) -> JSONResponse:
|
||||
try:
|
||||
raw_body = await request.body()
|
||||
payload = json.loads(raw_body)
|
||||
except Exception as parse_error:
|
||||
logger.error('Lava webhook: failed to parse JSON', parse_error=parse_error)
|
||||
return JSONResponse({'status': 'error'}, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
from app.services.lava_service import lava_service
|
||||
|
||||
received_signature = (request.headers.get('Authorization') or '').strip()
|
||||
if not lava_service.verify_webhook_signature(raw_body, received_signature):
|
||||
logger.warning('Lava webhook: invalid signature')
|
||||
return JSONResponse({'status': 'error'}, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
success = await _process_payment_service_callback(
|
||||
payment_service,
|
||||
payload,
|
||||
'process_lava_callback',
|
||||
)
|
||||
if not success:
|
||||
logger.error(
|
||||
'Lava webhook processing failed',
|
||||
order_id=payload.get('order_id'),
|
||||
invoice_id=payload.get('invoice_id'),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception('Lava webhook processing error', error=e)
|
||||
# Lava ожидает HTTP 200 как подтверждение приёма; иначе будет повтор до 5 раз раз в 150с
|
||||
return JSONResponse({'status': 'ok'}, status_code=status.HTTP_200_OK)
|
||||
|
||||
routes_registered = True
|
||||
|
||||
# Donut webhook (Donut P2P)
|
||||
if settings.is_donut_enabled():
|
||||
|
||||
@@ -1684,6 +1732,7 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
||||
'antilopay_enabled': settings.is_antilopay_enabled(),
|
||||
'jupiter_enabled': settings.is_jupiter_enabled(),
|
||||
'donut_enabled': settings.is_donut_enabled(),
|
||||
'lava_enabled': settings.is_lava_enabled(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user