feat(payments): add LAVA Business payment provider
Add a new provider module for LAVA Business (api.lava.ru): - invoice creation via POST /business/invoice/create signed with HMAC-SHA256 over the raw request body in the Signature header - webhook handling with Authorization-header signature verification that accepts both raw-body and sorted-keys JSON canonicalizations (legacy PHP SDK shops sign the latter), plus amount cross-check before finalizing a successful payment - pending invoice reuse through /business/invoice/status - Telegram pay_lava callback flow and Web App payment creation - admin settings manifest fields, presentation overrides, and RUB-only currency support declared on the provider SPEC Register the SPEC in the provider registry and wire the provider into the payment method order, success-email labels, and locale override prefixes.
This commit is contained in:
@@ -0,0 +1,877 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from aiogram import Bot, F, Router, types
|
||||
from aiohttp import web
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic_settings import SettingsConfigDict
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from config.settings import Settings
|
||||
from config.tariffs_config import (
|
||||
default_currency_key_for_settings,
|
||||
default_payment_currency_code_for_settings,
|
||||
)
|
||||
from db.dal import payment_dal
|
||||
|
||||
from .base import (
|
||||
PaymentProviderSpec,
|
||||
ProviderEnvConfig,
|
||||
ProviderManifestField,
|
||||
ServiceFactoryContext,
|
||||
WebAppPaymentContext,
|
||||
normalize_payment_currency_code,
|
||||
provider_env_file,
|
||||
provider_runtime_enabled,
|
||||
)
|
||||
from .shared import (
|
||||
HttpClientMixin,
|
||||
PaymentSuccessRequest,
|
||||
build_payment_record_payload,
|
||||
create_webapp_payment_record,
|
||||
decimal_amounts_equal,
|
||||
describe_payment,
|
||||
finalize_successful_payment,
|
||||
finalize_webapp_link_payment,
|
||||
first_value,
|
||||
format_decimal_amount,
|
||||
lookup_payment_by_order_or_provider_id,
|
||||
make_translator,
|
||||
notify_callback_parse_error,
|
||||
notify_payment_record_failure,
|
||||
notify_service_unavailable,
|
||||
notify_user_payment_failed,
|
||||
parse_payment_callback,
|
||||
payment_failed,
|
||||
payment_record_amounts,
|
||||
payment_unavailable,
|
||||
payment_units_for_activation,
|
||||
quote_hwid_callback_parts,
|
||||
render_link_or_fail,
|
||||
render_payment_link,
|
||||
)
|
||||
|
||||
_LOG = "lava"
|
||||
|
||||
# LAVA Business invoice statuses (https://dev.lava.ru/business-objects-invoice).
|
||||
_SUCCESS_STATUSES = {"success"}
|
||||
_FAILED_STATUSES = {"cancel", "cancelled", "error", "failed", "expired"}
|
||||
_PENDING_STATUSES = {"created", "pending", "processing"}
|
||||
|
||||
|
||||
class LavaConfig(ProviderEnvConfig):
|
||||
"""All LAVA Business env vars. Lives inside the provider module."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=provider_env_file(),
|
||||
env_file_encoding="utf-8",
|
||||
env_prefix="LAVA_",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
ENABLED: bool = Field(default=False)
|
||||
SHOP_ID: Optional[str] = None
|
||||
SECRET_KEY: Optional[str] = None
|
||||
WEBHOOK_SECRET: Optional[str] = None
|
||||
BASE_URL: str = Field(default="https://api.lava.ru")
|
||||
RETURN_URL: Optional[str] = None
|
||||
LIFETIME_MINUTES: Optional[int] = None
|
||||
INCLUDE_SERVICES: Optional[str] = None
|
||||
|
||||
@field_validator("LIFETIME_MINUTES", mode="before")
|
||||
@classmethod
|
||||
def _empty_to_none_int(cls, v):
|
||||
if isinstance(v, str):
|
||||
v = v.strip()
|
||||
if not v:
|
||||
return None
|
||||
return v
|
||||
|
||||
@field_validator("SHOP_ID", "SECRET_KEY", "WEBHOOK_SECRET", "RETURN_URL", mode="before")
|
||||
@classmethod
|
||||
def _strip_optional(cls, v):
|
||||
if isinstance(v, str) and not v.strip():
|
||||
return None
|
||||
return v
|
||||
|
||||
@property
|
||||
def webhook_path(self) -> str:
|
||||
return "/webhook/lava"
|
||||
|
||||
def full_webhook_url(self, base: Optional[str]) -> Optional[str]:
|
||||
if not base:
|
||||
return None
|
||||
return f"{base.rstrip('/')}{self.webhook_path}"
|
||||
|
||||
@property
|
||||
def include_services_list(self) -> List[str]:
|
||||
return [item.strip() for item in (self.INCLUDE_SERVICES or "").split(",") if item.strip()]
|
||||
|
||||
|
||||
class LavaPresentation(ProviderEnvConfig):
|
||||
"""Admin-tunable button text/icon overrides for LAVA."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=provider_env_file(),
|
||||
env_file_encoding="utf-8",
|
||||
env_prefix="PAYMENT_LAVA_",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
WEBAPP_LABEL_RU: Optional[str] = None
|
||||
WEBAPP_LABEL_EN: Optional[str] = None
|
||||
WEBAPP_ICON: Optional[str] = None
|
||||
TELEGRAM_LABEL_RU: Optional[str] = None
|
||||
TELEGRAM_LABEL_EN: Optional[str] = None
|
||||
TELEGRAM_EMOJI: Optional[str] = None
|
||||
|
||||
|
||||
def _canonical_json(payload: Dict[str, Any]) -> str:
|
||||
"""JSON with sorted keys, the way legacy LAVA PHP-SDK shops sign webhooks.
|
||||
|
||||
Only used as a webhook-verification fallback: outgoing requests sign the
|
||||
exact raw bytes that go on the wire, never a re-serialization. The
|
||||
``signature`` field is dropped and ``float n.0`` collapses to ``int``
|
||||
for PHP ``json_encode`` compatibility.
|
||||
"""
|
||||
|
||||
def normalize(value: Any) -> Any:
|
||||
if isinstance(value, float) and value.is_integer():
|
||||
return int(value)
|
||||
if isinstance(value, dict):
|
||||
return {key: normalize(item) for key, item in value.items() if key != "signature"}
|
||||
if isinstance(value, list):
|
||||
return [normalize(item) for item in value]
|
||||
return value
|
||||
|
||||
without_sig = {key: normalize(value) for key, value in payload.items() if key != "signature"}
|
||||
return json.dumps(without_sig, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
class LavaService(HttpClientMixin):
|
||||
"""Client for LAVA Business API (api.lava.ru).
|
||||
|
||||
Outgoing requests are signed with HMAC-SHA256 over the exact raw body
|
||||
bytes using ``LAVA_SECRET_KEY``; the hex digest travels in the
|
||||
``Signature`` HTTP header. Webhooks arrive signed with the shop's
|
||||
additional key (``LAVA_WEBHOOK_SECRET``) in the ``Authorization`` header;
|
||||
some shops sign the raw body, others a sorted-keys re-serialization, so
|
||||
verification accepts either canonicalization.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
bot: Bot,
|
||||
settings: Settings,
|
||||
config: LavaConfig,
|
||||
i18n: JsonI18n,
|
||||
async_session_factory: sessionmaker,
|
||||
subscription_service: SubscriptionService,
|
||||
referral_service: ReferralService,
|
||||
default_return_url: str,
|
||||
):
|
||||
self.bot = bot
|
||||
self.settings = settings
|
||||
self.config = config
|
||||
self.i18n = i18n
|
||||
self.async_session_factory = async_session_factory
|
||||
self.subscription_service = subscription_service
|
||||
self.referral_service = referral_service
|
||||
self._default_return_url = default_return_url
|
||||
|
||||
self._init_http_client(total_timeout=lambda: self.settings.PAYMENT_REQUEST_TIMEOUT_SECONDS)
|
||||
|
||||
if not self.configured:
|
||||
logging.warning("LavaService initialized but not fully configured. Payments disabled.")
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return bool(provider_runtime_enabled(self.config) and self.shop_id and self.secret_key)
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
return (self.config.BASE_URL or "https://api.lava.ru").rstrip("/")
|
||||
|
||||
@property
|
||||
def shop_id(self) -> str:
|
||||
return (self.config.SHOP_ID or "").strip()
|
||||
|
||||
@property
|
||||
def secret_key(self) -> str:
|
||||
return (self.config.SECRET_KEY or "").strip()
|
||||
|
||||
@property
|
||||
def webhook_secret(self) -> str:
|
||||
# LAVA signs webhooks with the shop's "additional key"; merchants that
|
||||
# use a single key can leave WEBHOOK_SECRET empty to reuse SECRET_KEY.
|
||||
return (self.config.WEBHOOK_SECRET or "").strip() or self.secret_key
|
||||
|
||||
@property
|
||||
def return_url(self) -> str:
|
||||
return self.config.RETURN_URL or f"https://t.me/{self._default_return_url}"
|
||||
|
||||
@property
|
||||
def lifetime_minutes(self) -> Optional[int]:
|
||||
return self.config.LIFETIME_MINUTES
|
||||
|
||||
def _hmac_hex(self, message: bytes, key: str) -> str:
|
||||
return hmac.new(key.encode("utf-8"), message, hashlib.sha256).hexdigest()
|
||||
|
||||
async def _post_signed(self, path: str, payload: Dict[str, Any]) -> Tuple[bool, Dict[str, Any]]:
|
||||
"""POST to LAVA signing the exact bytes that go on the wire."""
|
||||
url = f"{self.base_url}/{path.lstrip('/')}"
|
||||
body_bytes = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"Signature": self._hmac_hex(body_bytes, self.secret_key),
|
||||
}
|
||||
session = await self._get_session()
|
||||
try:
|
||||
async with session.post(url, data=body_bytes, headers=headers) as response:
|
||||
response_text = await response.text()
|
||||
try:
|
||||
response_data = json.loads(response_text) if response_text else {}
|
||||
except json.JSONDecodeError:
|
||||
logging.error("LAVA %s: invalid JSON response: %s", path, response_text[:500])
|
||||
return False, {"status": response.status, "message": "invalid_json"}
|
||||
if not isinstance(response_data, dict):
|
||||
response_data = {"data": response_data}
|
||||
api_status = str(response_data.get("status") or "").lower()
|
||||
if response.status != 200 or api_status == "error":
|
||||
logging.error(
|
||||
"LAVA %s: API error (http=%s, body=%s)",
|
||||
path,
|
||||
response.status,
|
||||
response_data,
|
||||
)
|
||||
return False, {
|
||||
"status": response.status,
|
||||
"message": response_data.get("error")
|
||||
or response_data.get("message")
|
||||
or "lava_api_error",
|
||||
"code": response_data.get("code"),
|
||||
}
|
||||
data = response_data.get("data")
|
||||
return True, data if isinstance(data, dict) else response_data
|
||||
except Exception as exc:
|
||||
logging.exception("LAVA %s: request failed.", path)
|
||||
return False, {"message": str(exc)}
|
||||
|
||||
async def create_payment(
|
||||
self,
|
||||
*,
|
||||
payment_db_id: int,
|
||||
amount: float,
|
||||
currency: Optional[str],
|
||||
description: Optional[str] = None,
|
||||
) -> Tuple[bool, Dict[str, Any]]:
|
||||
if not self.configured:
|
||||
logging.error("LavaService is not configured. Cannot create payment.")
|
||||
return False, {"message": "service_not_configured"}
|
||||
|
||||
currency_code = normalize_payment_currency_code(
|
||||
currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||
)
|
||||
if currency_code != "RUB":
|
||||
return False, {
|
||||
"message": "unsupported_currency",
|
||||
"currency": currency_code,
|
||||
"supported_currencies": ["RUB"],
|
||||
}
|
||||
|
||||
body: Dict[str, Any] = {
|
||||
"sum": float(format_decimal_amount(amount)),
|
||||
"orderId": str(payment_db_id),
|
||||
"shopId": self.shop_id,
|
||||
}
|
||||
hook_url = self.config.full_webhook_url(getattr(self.settings, "WEBHOOK_BASE_URL", None))
|
||||
if hook_url:
|
||||
body["hookUrl"] = hook_url[:500]
|
||||
if self.return_url:
|
||||
body["successUrl"] = self.return_url[:500]
|
||||
body["failUrl"] = self.return_url[:500]
|
||||
if self.lifetime_minutes:
|
||||
# LAVA accepts 1..7200 minutes (5 days).
|
||||
body["expire"] = max(1, min(7200, int(self.lifetime_minutes)))
|
||||
if description:
|
||||
body["comment"] = description[:255]
|
||||
include_services = self.config.include_services_list
|
||||
if include_services:
|
||||
body["includeService"] = include_services
|
||||
|
||||
return await self._post_signed("/business/invoice/create", body)
|
||||
|
||||
async def get_invoice_status(
|
||||
self,
|
||||
*,
|
||||
order_id: Optional[str] = None,
|
||||
invoice_id: Optional[str] = None,
|
||||
) -> Tuple[bool, Dict[str, Any]]:
|
||||
if not self.configured:
|
||||
return False, {"message": "service_not_configured"}
|
||||
if not order_id and not invoice_id:
|
||||
return False, {"message": "missing_identifier"}
|
||||
|
||||
body: Dict[str, Any] = {"shopId": self.shop_id}
|
||||
if invoice_id:
|
||||
body["invoiceId"] = str(invoice_id)
|
||||
if order_id:
|
||||
body["orderId"] = str(order_id)
|
||||
return await self._post_signed("/business/invoice/status", body)
|
||||
|
||||
async def try_reuse_pending_payment(self, payment: Any) -> Optional[str]:
|
||||
provider_payment_id = str(getattr(payment, "provider_payment_id", None) or "").strip()
|
||||
payment_url = str(getattr(payment, "provider_payment_url", None) or "").strip()
|
||||
if not provider_payment_id or not payment_url:
|
||||
return None
|
||||
|
||||
success, data = await self.get_invoice_status(
|
||||
order_id=str(payment.payment_id),
|
||||
invoice_id=provider_payment_id,
|
||||
)
|
||||
if not success or str(data.get("status") or "").lower() not in _PENDING_STATUSES:
|
||||
return None
|
||||
returned_ids = {str(data.get("id") or ""), str(data.get("invoice_id") or "")}
|
||||
if provider_payment_id not in returned_ids:
|
||||
return None
|
||||
returned_order_id = str(data.get("order_id") or data.get("orderId") or "")
|
||||
if returned_order_id and returned_order_id != str(payment.payment_id):
|
||||
return None
|
||||
return payment_url
|
||||
|
||||
def verify_webhook_signature(self, raw_body: bytes, received_signature: str) -> bool:
|
||||
"""Verify the ``Authorization`` header HMAC on a LAVA webhook.
|
||||
|
||||
Accepts HMAC of the raw body (current api.lava.ru contract) or of a
|
||||
sorted-keys re-serialization (legacy PHP-SDK shops sign that instead).
|
||||
"""
|
||||
received = str(received_signature or "").strip()
|
||||
if not received:
|
||||
logging.warning("LAVA webhook: missing signature header.")
|
||||
return False
|
||||
secret = self.webhook_secret
|
||||
if not secret:
|
||||
logging.error("LAVA webhook: no webhook secret configured.")
|
||||
return False
|
||||
|
||||
expected_raw = self._hmac_hex(raw_body, secret)
|
||||
if hmac.compare_digest(expected_raw.lower(), received.lower()):
|
||||
return True
|
||||
|
||||
try:
|
||||
payload = json.loads(raw_body)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
if not isinstance(payload, dict):
|
||||
return False
|
||||
expected_canonical = self._hmac_hex(_canonical_json(payload).encode("utf-8"), secret)
|
||||
return hmac.compare_digest(expected_canonical.lower(), received.lower())
|
||||
|
||||
async def webhook_route(self, request: web.Request) -> web.Response:
|
||||
if not self.configured:
|
||||
return web.json_response({"status": False, "msg": "lava_disabled"}, status=503)
|
||||
|
||||
raw_body = await request.read()
|
||||
signature = request.headers.get("Authorization") or request.headers.get("Signature") or ""
|
||||
if not self.verify_webhook_signature(raw_body, signature):
|
||||
logging.error("LAVA webhook: invalid signature.")
|
||||
return web.json_response({"status": False, "msg": "invalid_signature"}, status=403)
|
||||
|
||||
try:
|
||||
payload = json.loads(raw_body)
|
||||
except (ValueError, TypeError):
|
||||
logging.exception("LAVA webhook: failed to parse JSON.")
|
||||
return web.json_response({"status": False, "msg": "bad_request"}, status=400)
|
||||
if not isinstance(payload, dict):
|
||||
logging.error("LAVA webhook: unexpected payload type.")
|
||||
return web.json_response({"status": False, "msg": "bad_request"}, status=400)
|
||||
|
||||
provider_payment_id = str(payload.get("invoice_id") or payload.get("id") or "")
|
||||
order_id_raw = payload.get("order_id") or payload.get("orderId")
|
||||
status = str(payload.get("status") or "").lower()
|
||||
|
||||
async with self.async_session_factory() as session:
|
||||
payment = await lookup_payment_by_order_or_provider_id(
|
||||
session,
|
||||
order_id_raw=order_id_raw,
|
||||
provider_payment_id=provider_payment_id or None,
|
||||
)
|
||||
if not payment:
|
||||
logging.error(
|
||||
"LAVA webhook: payment not found (order_id=%s, provider_id=%s)",
|
||||
order_id_raw,
|
||||
provider_payment_id,
|
||||
)
|
||||
return web.json_response({"status": False, "msg": "payment_not_found"}, status=404)
|
||||
|
||||
resolved_provider_id = provider_payment_id or str(payment.payment_id)
|
||||
sale_mode = payment.sale_mode or (
|
||||
"traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||
)
|
||||
payment_months = payment_units_for_activation(payment, sale_mode)
|
||||
|
||||
if status in _SUCCESS_STATUSES:
|
||||
if payment.status == "succeeded":
|
||||
logging.info("LAVA webhook: payment %s already succeeded.", payment.payment_id)
|
||||
return web.json_response({"status": True})
|
||||
|
||||
webhook_amount = payload.get("amount")
|
||||
if webhook_amount is not None and not decimal_amounts_equal(
|
||||
webhook_amount, payment.amount
|
||||
):
|
||||
logging.error(
|
||||
"LAVA webhook: amount mismatch for payment %s (expected=%s, received=%s)",
|
||||
payment.payment_id,
|
||||
payment.amount,
|
||||
webhook_amount,
|
||||
)
|
||||
return web.json_response(
|
||||
{"status": False, "msg": "amount_mismatch"}, status=400
|
||||
)
|
||||
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment.payment_id,
|
||||
resolved_provider_id,
|
||||
"succeeded",
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception(
|
||||
"LAVA webhook: failed to mark payment %s as succeeded.",
|
||||
resolved_provider_id,
|
||||
)
|
||||
return web.json_response(
|
||||
{"status": False, "msg": "processing_error"}, status=500
|
||||
)
|
||||
|
||||
outcome = await finalize_successful_payment(
|
||||
PaymentSuccessRequest(
|
||||
bot=self.bot,
|
||||
settings=self.settings,
|
||||
i18n=self.i18n,
|
||||
session=session,
|
||||
subscription_service=self.subscription_service,
|
||||
referral_service=self.referral_service,
|
||||
payment=payment,
|
||||
user_id=payment.user_id,
|
||||
amount=float(payment.amount),
|
||||
currency=payment.currency,
|
||||
sale_mode=sale_mode,
|
||||
months=payment_months,
|
||||
traffic_amount=float(payment_months),
|
||||
provider_subscription="lava",
|
||||
provider_notification="lava",
|
||||
db_user=payment.user,
|
||||
log_prefix="LAVA webhook",
|
||||
)
|
||||
)
|
||||
if outcome is None:
|
||||
return web.json_response(
|
||||
{"status": False, "msg": "processing_error"}, status=500
|
||||
)
|
||||
return web.json_response({"status": True})
|
||||
|
||||
if status in _FAILED_STATUSES:
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment.payment_id,
|
||||
resolved_provider_id,
|
||||
"failed",
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception(
|
||||
"LAVA webhook: failed to mark payment %s as failed.",
|
||||
resolved_provider_id,
|
||||
)
|
||||
return web.json_response(
|
||||
{"status": False, "msg": "processing_error"}, status=500
|
||||
)
|
||||
await notify_user_payment_failed(
|
||||
bot=self.bot,
|
||||
settings=self.settings,
|
||||
i18n=self.i18n,
|
||||
session=session,
|
||||
payment=payment,
|
||||
)
|
||||
return web.json_response({"status": True})
|
||||
|
||||
if status in _PENDING_STATUSES:
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment.payment_id,
|
||||
resolved_provider_id,
|
||||
"pending_lava",
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception(
|
||||
"LAVA webhook: failed to update pending status for %s.",
|
||||
resolved_provider_id,
|
||||
)
|
||||
return web.json_response({"status": True})
|
||||
|
||||
logging.warning(
|
||||
"LAVA webhook: unhandled status '%s' for payment %s",
|
||||
status,
|
||||
resolved_provider_id,
|
||||
)
|
||||
return web.json_response({"status": True})
|
||||
|
||||
|
||||
async def lava_webhook_route(request: web.Request) -> web.Response:
|
||||
service: LavaService = request.app["lava_service"]
|
||||
return await service.webhook_route(request)
|
||||
|
||||
|
||||
router = Router(name="user_subscription_payments_lava_router")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_lava:"))
|
||||
async def pay_lava_callback_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
lava_service: LavaService,
|
||||
session: AsyncSession,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
translator = make_translator(i18n, current_lang)
|
||||
|
||||
if not i18n or not callback.message:
|
||||
await notify_callback_parse_error(callback, translator)
|
||||
return
|
||||
|
||||
if not SPEC.is_available_to_user(
|
||||
settings,
|
||||
user_id=callback.from_user.id,
|
||||
require_configured=False,
|
||||
):
|
||||
await notify_service_unavailable(callback, translator)
|
||||
return
|
||||
|
||||
if not lava_service or not lava_service.configured:
|
||||
logging.error("LAVA service is not configured or unavailable.")
|
||||
await notify_service_unavailable(callback, translator)
|
||||
return
|
||||
|
||||
parts = parse_payment_callback(callback.data or "")
|
||||
if not parts:
|
||||
logging.error("Invalid pay_lava data in callback: %s", callback.data)
|
||||
await notify_callback_parse_error(callback, translator)
|
||||
return
|
||||
parts, hwid_quote = await quote_hwid_callback_parts(
|
||||
session=session,
|
||||
user_id=callback.from_user.id,
|
||||
parts=parts,
|
||||
subscription_service=lava_service.subscription_service,
|
||||
currency=default_currency_key_for_settings(settings),
|
||||
)
|
||||
if not parts:
|
||||
await notify_callback_parse_error(callback, translator)
|
||||
return
|
||||
|
||||
currency_code = default_payment_currency_code_for_settings(settings)
|
||||
payment_description = describe_payment(translator, parts)
|
||||
record_payload = build_payment_record_payload(
|
||||
user_id=callback.from_user.id,
|
||||
amount=parts.price,
|
||||
currency=currency_code,
|
||||
status="pending_lava",
|
||||
description=payment_description,
|
||||
months=parts.months,
|
||||
provider="lava",
|
||||
sale_mode=parts.sale_mode,
|
||||
hwid_quote=hwid_quote,
|
||||
)
|
||||
|
||||
reuse_amounts = payment_record_amounts(
|
||||
months=parts.months,
|
||||
sale_mode=parts.sale_mode,
|
||||
hwid_device_count=hwid_quote.get("device_count") if hwid_quote else None,
|
||||
)
|
||||
reusable_payment = await payment_dal.find_recent_pending_provider_payment(
|
||||
session,
|
||||
user_id=callback.from_user.id,
|
||||
provider="lava",
|
||||
pending_status="pending_lava",
|
||||
amount=parts.price,
|
||||
currency=currency_code,
|
||||
sale_mode=parts.sale_mode,
|
||||
months=reuse_amounts.months,
|
||||
purchased_gb=reuse_amounts.purchased_gb,
|
||||
purchased_hwid_devices=reuse_amounts.purchased_hwid_devices,
|
||||
tariff_key=reuse_amounts.tariff_key,
|
||||
)
|
||||
if reusable_payment is not None:
|
||||
reusable_url = await lava_service.try_reuse_pending_payment(reusable_payment)
|
||||
if reusable_url:
|
||||
await render_payment_link(
|
||||
callback,
|
||||
translator=translator,
|
||||
current_lang=current_lang,
|
||||
i18n=i18n,
|
||||
parts=parts,
|
||||
payment_url=reusable_url,
|
||||
log_prefix=_LOG,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
payment_record = await payment_dal.create_payment_record(session, record_payload)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.exception(
|
||||
"LAVA: failed to create payment record for user %s.", callback.from_user.id
|
||||
)
|
||||
await notify_payment_record_failure(callback, translator)
|
||||
return
|
||||
|
||||
success, response_data = await lava_service.create_payment(
|
||||
payment_db_id=payment_record.payment_id,
|
||||
amount=parts.price,
|
||||
currency=currency_code,
|
||||
description=payment_description,
|
||||
)
|
||||
await render_link_or_fail(
|
||||
callback,
|
||||
translator=translator,
|
||||
current_lang=current_lang,
|
||||
i18n=i18n,
|
||||
parts=parts,
|
||||
session=session,
|
||||
payment=payment_record,
|
||||
api_success=success,
|
||||
payment_url=first_value(response_data, "url", "payment_url", "paymentUrl"),
|
||||
provider_payment_id=first_value(response_data, "id", "invoice_id"),
|
||||
log_prefix=_LOG,
|
||||
)
|
||||
|
||||
|
||||
def create_service(ctx: ServiceFactoryContext) -> LavaService:
|
||||
bundle = ctx.config_for("lava_service")
|
||||
config = bundle.config if bundle and isinstance(bundle.config, LavaConfig) else LavaConfig()
|
||||
return LavaService(
|
||||
bot=ctx.bot,
|
||||
settings=ctx.settings,
|
||||
config=config,
|
||||
i18n=ctx.i18n,
|
||||
async_session_factory=ctx.async_session_factory,
|
||||
subscription_service=ctx.subscription_service,
|
||||
referral_service=ctx.referral_service,
|
||||
default_return_url=ctx.bot_username_for_default_return,
|
||||
)
|
||||
|
||||
|
||||
async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
||||
settings = ctx.request.app["settings"]
|
||||
service: LavaService = ctx.request.app["lava_service"]
|
||||
if not service or not service.configured:
|
||||
return payment_unavailable()
|
||||
|
||||
currency = ctx.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||
try:
|
||||
payment = await create_webapp_payment_record(
|
||||
ctx,
|
||||
amount=ctx.price,
|
||||
currency=currency,
|
||||
status="pending_lava",
|
||||
provider="lava",
|
||||
)
|
||||
success, response_data = await service.create_payment(
|
||||
payment_db_id=payment.payment_id,
|
||||
amount=ctx.price,
|
||||
currency=currency,
|
||||
description=ctx.description,
|
||||
)
|
||||
except Exception:
|
||||
await ctx.session.rollback()
|
||||
logging.exception("LAVA WebApp payment failed")
|
||||
return payment_failed()
|
||||
|
||||
return await finalize_webapp_link_payment(
|
||||
session=ctx.session,
|
||||
payment=payment,
|
||||
api_success=success,
|
||||
payment_url=(
|
||||
first_value(response_data, "url", "payment_url", "paymentUrl") if success else None
|
||||
),
|
||||
provider_payment_id=first_value(response_data, "id", "invoice_id"),
|
||||
log_prefix="LAVA",
|
||||
)
|
||||
|
||||
|
||||
async def reuse_webapp_payment(ctx: WebAppPaymentContext, payment: Any) -> Optional[str]:
|
||||
service: LavaService = ctx.request.app.get("lava_service")
|
||||
if not service or not service.configured:
|
||||
return None
|
||||
return await service.try_reuse_pending_payment(payment)
|
||||
|
||||
|
||||
_PRESENTATION_MANIFEST = tuple(
|
||||
ProviderManifestField(
|
||||
key=key,
|
||||
type=type_,
|
||||
label=label,
|
||||
description=description,
|
||||
placeholder=placeholder,
|
||||
subsection="LAVA",
|
||||
target="presentation",
|
||||
attr=attr,
|
||||
)
|
||||
for key, type_, label, description, placeholder, attr in (
|
||||
(
|
||||
"PAYMENT_LAVA_WEBAPP_LABEL_RU",
|
||||
"string",
|
||||
"WebApp button text (RU)",
|
||||
"Custom Russian text shown in the Web App payment method button.",
|
||||
"",
|
||||
"WEBAPP_LABEL_RU",
|
||||
),
|
||||
(
|
||||
"PAYMENT_LAVA_WEBAPP_LABEL_EN",
|
||||
"string",
|
||||
"WebApp button text (EN)",
|
||||
"Custom English text shown in the Web App payment method button.",
|
||||
"",
|
||||
"WEBAPP_LABEL_EN",
|
||||
),
|
||||
(
|
||||
"PAYMENT_LAVA_WEBAPP_ICON",
|
||||
"icon",
|
||||
"WebApp button icon",
|
||||
"Lucide icon name rendered inside the Web App payment method button.",
|
||||
"CreditCard",
|
||||
"WEBAPP_ICON",
|
||||
),
|
||||
(
|
||||
"PAYMENT_LAVA_TELEGRAM_LABEL_RU",
|
||||
"string",
|
||||
"Telegram button text (RU)",
|
||||
"Custom Russian text shown in Telegram bot payment buttons.",
|
||||
"",
|
||||
"TELEGRAM_LABEL_RU",
|
||||
),
|
||||
(
|
||||
"PAYMENT_LAVA_TELEGRAM_LABEL_EN",
|
||||
"string",
|
||||
"Telegram button text (EN)",
|
||||
"Custom English text shown in Telegram bot payment buttons.",
|
||||
"",
|
||||
"TELEGRAM_LABEL_EN",
|
||||
),
|
||||
(
|
||||
"PAYMENT_LAVA_TELEGRAM_EMOJI",
|
||||
"string",
|
||||
"Telegram button emoji",
|
||||
"Emoji prepended to the Telegram bot payment button when customized.",
|
||||
"💳",
|
||||
"TELEGRAM_EMOJI",
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
_CONFIG_MANIFEST = (
|
||||
ProviderManifestField("LAVA_ENABLED", "bool", "Включена", subsection="LAVA", attr="ENABLED"),
|
||||
ProviderManifestField("LAVA_SHOP_ID", "string", "Shop ID", subsection="LAVA", attr="SHOP_ID"),
|
||||
ProviderManifestField(
|
||||
"LAVA_SECRET_KEY",
|
||||
"string",
|
||||
"Secret key",
|
||||
description="Signs outgoing API requests (HMAC-SHA256 in the Signature header).",
|
||||
subsection="LAVA",
|
||||
secret=True,
|
||||
attr="SECRET_KEY",
|
||||
),
|
||||
ProviderManifestField(
|
||||
"LAVA_WEBHOOK_SECRET",
|
||||
"string",
|
||||
"Webhook secret",
|
||||
description=(
|
||||
"The shop's additional key used to verify webhook signatures. "
|
||||
"Leave empty to reuse the secret key."
|
||||
),
|
||||
subsection="LAVA",
|
||||
secret=True,
|
||||
attr="WEBHOOK_SECRET",
|
||||
),
|
||||
ProviderManifestField(
|
||||
"LAVA_BASE_URL",
|
||||
"url",
|
||||
"Base URL",
|
||||
placeholder="https://api.lava.ru",
|
||||
subsection="LAVA",
|
||||
attr="BASE_URL",
|
||||
),
|
||||
ProviderManifestField(
|
||||
"LAVA_RETURN_URL", "url", "Return URL", subsection="LAVA", attr="RETURN_URL"
|
||||
),
|
||||
ProviderManifestField(
|
||||
"LAVA_LIFETIME_MINUTES",
|
||||
"int",
|
||||
"Payment link lifetime (minutes)",
|
||||
description="1..7200; leave empty for the LAVA default.",
|
||||
subsection="LAVA",
|
||||
min=1,
|
||||
max=7200,
|
||||
attr="LIFETIME_MINUTES",
|
||||
),
|
||||
ProviderManifestField(
|
||||
"LAVA_INCLUDE_SERVICES",
|
||||
"string",
|
||||
"Payment services filter",
|
||||
description=(
|
||||
"Comma-separated LAVA pay services to show on the payment page "
|
||||
"(e.g. card,sbp). Empty shows everything enabled for the shop."
|
||||
),
|
||||
placeholder="card,sbp",
|
||||
subsection="LAVA",
|
||||
attr="INCLUDE_SERVICES",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
SPEC = PaymentProviderSpec(
|
||||
id="lava",
|
||||
provider_key="lava",
|
||||
label="LAVA",
|
||||
webapp_label="LAVA",
|
||||
webapp_labels={"ru": "LAVA", "en": "LAVA"},
|
||||
webapp_icon="CreditCard",
|
||||
telegram_labels={"ru": "LAVA", "en": "LAVA"},
|
||||
telegram_emoji="💳",
|
||||
pending_status="pending_lava",
|
||||
enabled=lambda config: bool(getattr(config, "ENABLED", False)),
|
||||
service_key="lava_service",
|
||||
callback_prefix="pay_lava",
|
||||
router=router,
|
||||
create_service=create_service,
|
||||
webhook_path=lambda source: "/webhook/lava",
|
||||
webhook_route=lava_webhook_route,
|
||||
create_webapp_payment=create_webapp_payment,
|
||||
reuse_webapp_payment=reuse_webapp_payment,
|
||||
config_class=LavaConfig,
|
||||
presentation_class=LavaPresentation,
|
||||
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
|
||||
supported_currencies=("RUB",),
|
||||
currency_support_note="LAVA Business invoices are issued in RUB only.",
|
||||
currency_support_url="https://dev.lava.ru/",
|
||||
)
|
||||
Reference in New Issue
Block a user