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',
|
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:
|
else:
|
||||||
# For other payment methods, redirect to bot
|
# For other payment methods, redirect to bot
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -1151,6 +1182,21 @@ def _get_status_info(record: PendingPayment) -> tuple[str, str]:
|
|||||||
}
|
}
|
||||||
return mapping.get(status, ('❓', 'Неизвестно'))
|
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 '❓', 'Неизвестно'
|
return '❓', 'Неизвестно'
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -753,6 +753,25 @@ class Settings(BaseSettings):
|
|||||||
DONUT_SBP_QR_ENABLED: bool = False
|
DONUT_SBP_QR_ENABLED: bool = False
|
||||||
DONUT_SBP_QR_DISPLAY_NAME: str = 'СБП QR (Donut)'
|
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 (paymentpage.etoplatezhi.ru)
|
||||||
ETOPLATEZHI_ENABLED: bool = False
|
ETOPLATEZHI_ENABLED: bool = False
|
||||||
ETOPLATEZHI_PROJECT_ID: int | None = None
|
ETOPLATEZHI_PROJECT_ID: int | None = None
|
||||||
@@ -2343,6 +2362,41 @@ class Settings(BaseSettings):
|
|||||||
def get_donut_sbp_qr_display_name_html(self) -> str:
|
def get_donut_sbp_qr_display_name_html(self) -> str:
|
||||||
return html.escape(self.get_donut_sbp_qr_display_name())
|
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:
|
def is_etoplatezhi_enabled(self) -> bool:
|
||||||
return (
|
return (
|
||||||
self.ETOPLATEZHI_ENABLED
|
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'
|
ANTILOPAY = 'antilopay'
|
||||||
JUPITER = 'jupiter'
|
JUPITER = 'jupiter'
|
||||||
DONUT = 'donut'
|
DONUT = 'donut'
|
||||||
|
LAVA = 'lava'
|
||||||
MANUAL = 'manual'
|
MANUAL = 'manual'
|
||||||
BALANCE = 'balance'
|
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})>'
|
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):
|
class PromoGroup(Base):
|
||||||
__tablename__ = 'promo_groups'
|
__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)
|
await process_donut_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||||
return True
|
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':
|
if payment_method == 'riopay':
|
||||||
from .riopay import process_riopay_payment_amount
|
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_topup, F.data == 'topup_donut_sbp')
|
||||||
dp.callback_query.register(start_donut_sbp_qr_topup, F.data == 'topup_donut_sbp_qr')
|
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
|
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_'))
|
||||||
|
|||||||
@@ -2060,6 +2060,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_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():
|
if settings.is_support_topup_enabled():
|
||||||
keyboard.append(
|
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'},
|
{'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',
|
'antilopay',
|
||||||
'jupiter',
|
'jupiter',
|
||||||
'donut',
|
'donut',
|
||||||
|
'lava',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ 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.jupiter import JupiterPaymentMixin
|
from app.services.payment.jupiter import JupiterPaymentMixin
|
||||||
from app.services.payment.kassa_ai import KassaAiPaymentMixin
|
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.overpay import OverpayPaymentMixin
|
||||||
from app.services.payment.paypear import PayPearPaymentMixin
|
from app.services.payment.paypear import PayPearPaymentMixin
|
||||||
from app.services.payment.riopay import RioPayPaymentMixin
|
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)
|
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
|
# 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] = {
|
||||||
@@ -657,6 +693,7 @@ class PaymentService(
|
|||||||
AntilopayPaymentMixin,
|
AntilopayPaymentMixin,
|
||||||
JupiterPaymentMixin,
|
JupiterPaymentMixin,
|
||||||
DonutPaymentMixin,
|
DonutPaymentMixin,
|
||||||
|
LavaPaymentMixin,
|
||||||
):
|
):
|
||||||
"""Основной интерфейс платежей, делегирующий работу специализированным mixin-ам."""
|
"""Основной интерфейс платежей, делегирующий работу специализированным mixin-ам."""
|
||||||
|
|
||||||
@@ -1252,6 +1289,28 @@ class PaymentService(
|
|||||||
}
|
}
|
||||||
return None
|
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 ---------------------------------------------------
|
# --- Telegram Stars ---------------------------------------------------
|
||||||
if payment_method == 'telegram_stars':
|
if payment_method == 'telegram_stars':
|
||||||
if not settings.TELEGRAM_STARS_ENABLED:
|
if not settings.TELEGRAM_STARS_ENABLED:
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ class BotConfigurationService:
|
|||||||
'ETOPLATEZHI': '💳 Etoplatezhi',
|
'ETOPLATEZHI': '💳 Etoplatezhi',
|
||||||
'JUPITER': '🪐 Jupiter',
|
'JUPITER': '🪐 Jupiter',
|
||||||
'DONUT': '🍩 Donut',
|
'DONUT': '🍩 Donut',
|
||||||
|
'LAVA': '🌋 Lava',
|
||||||
'YOOKASSA': '🟣 YooKassa',
|
'YOOKASSA': '🟣 YooKassa',
|
||||||
'PLATEGA': '💳 {platega_name}',
|
'PLATEGA': '💳 {platega_name}',
|
||||||
'TRIBUTE': '🎁 Tribute',
|
'TRIBUTE': '🎁 Tribute',
|
||||||
@@ -167,6 +168,7 @@ class BotConfigurationService:
|
|||||||
'ETOPLATEZHI': 'Etoplatezhi: paymentpage.etoplatezhi.ru, оплата картой и через СБП.',
|
'ETOPLATEZHI': 'Etoplatezhi: paymentpage.etoplatezhi.ru, оплата картой и через СБП.',
|
||||||
'JUPITER': 'Jupiter (FPGate P2P v2.1): app.juppiter.tech, эквайринг СБП с HMAC-SHA256.',
|
'JUPITER': 'Jupiter (FPGate P2P v2.1): app.juppiter.tech, эквайринг СБП с HMAC-SHA256.',
|
||||||
'DONUT': 'Donut P2P: gw.donut.business, P2P-оплата картой, СБП по телефону и QR.',
|
'DONUT': 'Donut P2P: gw.donut.business, P2P-оплата картой, СБП по телефону и QR.',
|
||||||
|
'LAVA': 'Lava Business: gate.lava.ru, оплата картой и СБП с HMAC-SHA256 и подтверждением через webhook.',
|
||||||
'PLATEGA': '{platega_name}: merchant ID, секрет, ссылки возврата и методы оплаты.',
|
'PLATEGA': '{platega_name}: merchant ID, секрет, ссылки возврата и методы оплаты.',
|
||||||
'MULENPAY': 'Платежи {mulenpay_name} и параметры магазина.',
|
'MULENPAY': 'Платежи {mulenpay_name} и параметры магазина.',
|
||||||
'PAL24': 'PAL24 / PayPalych подключения и лимиты.',
|
'PAL24': 'PAL24 / PayPalych подключения и лимиты.',
|
||||||
@@ -385,6 +387,7 @@ class BotConfigurationService:
|
|||||||
'ETOPLATEZHI_': 'ETOPLATEZHI',
|
'ETOPLATEZHI_': 'ETOPLATEZHI',
|
||||||
'JUPITER_': 'JUPITER',
|
'JUPITER_': 'JUPITER',
|
||||||
'DONUT_': 'DONUT',
|
'DONUT_': 'DONUT',
|
||||||
|
'LAVA_': 'LAVA',
|
||||||
'PLATEGA_': 'PLATEGA',
|
'PLATEGA_': 'PLATEGA',
|
||||||
'MULENPAY_': 'MULENPAY',
|
'MULENPAY_': 'MULENPAY',
|
||||||
'PAL24_': 'PAL24',
|
'PAL24_': 'PAL24',
|
||||||
|
|||||||
@@ -1610,6 +1610,54 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
|
|||||||
|
|
||||||
routes_registered = True
|
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)
|
# Donut webhook (Donut P2P)
|
||||||
if settings.is_donut_enabled():
|
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(),
|
'antilopay_enabled': settings.is_antilopay_enabled(),
|
||||||
'jupiter_enabled': settings.is_jupiter_enabled(),
|
'jupiter_enabled': settings.is_jupiter_enabled(),
|
||||||
'donut_enabled': settings.is_donut_enabled(),
|
'donut_enabled': settings.is_donut_enabled(),
|
||||||
|
'lava_enabled': settings.is_lava_enabled(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""create lava_payments table
|
||||||
|
|
||||||
|
Revision ID: 0074
|
||||||
|
Revises: 0073
|
||||||
|
Create Date: 2026-05-04
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = '0074'
|
||||||
|
down_revision: Union[str, None] = '0073'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'lava_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('lava_invoice_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('lava_payments')
|
||||||
Reference in New Issue
Block a user