From bb844869ee8b3624ff8eabd349862a13e5388bb0 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Wed, 10 Jun 2026 23:32:09 +0300 Subject: [PATCH] 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. --- .../bot/app/web/admin_settings_manifest.py | 2 +- backend/bot/payment_providers/lava.py | 877 ++++++++++++++++++ backend/bot/payment_providers/registry.py | 14 +- .../bot/services/locale_override_service.py | 1 + .../subscription_service_impl/payments.py | 1 + backend/config/settings.py | 3 +- 6 files changed, 895 insertions(+), 3 deletions(-) create mode 100644 backend/bot/payment_providers/lava.py diff --git a/backend/bot/app/web/admin_settings_manifest.py b/backend/bot/app/web/admin_settings_manifest.py index 4d3c93e..6c2e16d 100644 --- a/backend/bot/app/web/admin_settings_manifest.py +++ b/backend/bot/app/web/admin_settings_manifest.py @@ -379,7 +379,7 @@ SETTINGS_MANIFEST: List[SettingField] = [ "string", "payments", "Порядок методов оплаты", - "Через запятую: severpay,freekassa,yookassa,platega,stars,cryptopay,heleket,paykilla", + "Через запятую: severpay,freekassa,yookassa,platega,stars,cryptopay,heleket,paykilla,lava", subsection="common", ), # ─── Trial ───────────────────────────────────────────────────── diff --git a/backend/bot/payment_providers/lava.py b/backend/bot/payment_providers/lava.py new file mode 100644 index 0000000..f8154a0 --- /dev/null +++ b/backend/bot/payment_providers/lava.py @@ -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/", +) diff --git a/backend/bot/payment_providers/registry.py b/backend/bot/payment_providers/registry.py index 673a10e..6cf8cb0 100644 --- a/backend/bot/payment_providers/registry.py +++ b/backend/bot/payment_providers/registry.py @@ -2,7 +2,18 @@ from __future__ import annotations from typing import Any, Dict, Iterable, List, Mapping, Optional -from . import cryptopay, freekassa, heleket, paykilla, platega, severpay, stars, wata, yookassa +from . import ( + cryptopay, + freekassa, + heleket, + lava, + paykilla, + platega, + severpay, + stars, + wata, + yookassa, +) from .base import ( PaymentProviderPresentation, PaymentProviderSpec, @@ -22,6 +33,7 @@ PAYMENT_PROVIDER_SPECS: tuple[PaymentProviderSpec, ...] = ( cryptopay.SPEC, heleket.SPEC, paykilla.SPEC, + lava.SPEC, ) diff --git a/backend/bot/services/locale_override_service.py b/backend/bot/services/locale_override_service.py index 7b50740..1f1291f 100644 --- a/backend/bot/services/locale_override_service.py +++ b/backend/bot/services/locale_override_service.py @@ -232,6 +232,7 @@ LOCALE_GROUPS = [ "admin_settings_field_cryptopay_", "admin_settings_field_wata_", "admin_settings_field_heleket_", + "admin_settings_field_lava_", ), }, { diff --git a/backend/bot/services/subscription_service_impl/payments.py b/backend/bot/services/subscription_service_impl/payments.py index 419bc2a..356c164 100644 --- a/backend/bot/services/subscription_service_impl/payments.py +++ b/backend/bot/services/subscription_service_impl/payments.py @@ -13,6 +13,7 @@ class PaymentContextMixin: "platega": "Platega", "severpay": "SeverPay", "wata": "Wata", + "lava": "LAVA", "cryptopay": "Crypto Pay", "paykilla": "PayKilla", "telegram_stars": "Telegram Stars", diff --git a/backend/config/settings.py b/backend/config/settings.py index c848bdf..56504c9 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -329,7 +329,7 @@ class Settings(BaseSettings): STARS_ADMIN_ONLY_ENABLED: bool = Field(default=False) PAYMENT_METHODS_ORDER: Optional[str] = Field( default=None, - description="Comma-separated list of payment methods to show (e.g., severpay,wata,freekassa,yookassa,platega,stars,cryptopay,heleket,paykilla)", # noqa: E501 + description="Comma-separated list of payment methods to show (e.g., severpay,wata,freekassa,yookassa,platega,stars,cryptopay,heleket,paykilla,lava)", # noqa: E501 ) SUBSCRIPTION_PURCHASE_DESCRIPTION_ENABLED: bool = Field( default=True, @@ -1056,6 +1056,7 @@ class Settings(BaseSettings): "cryptopay", "heleket", "paykilla", + "lava", ] # Make sure default_order itself includes every registered spec. for sid in spec_ids: