Compare commits

..
4 Commits
Author SHA1 Message Date
3252a8 dc4da78fc4 docs: document LAVA provider setup
Describe LAVA Business configuration: env keys, webhook URL and
signature scheme, RUB-only invoices, and the include-services filter.
Mention LAVA in the provider lists of the README, admin panel page,
and tariff currency matrix.
2026-06-10 23:32:39 +03:00
3252a8 2074e934ce test(payments): cover LAVA provider contract
Pin the LAVA integration contract: raw-body HMAC signature in the
Signature header (never a body-embedded field or sorted re-encode),
tolerant webhook signature verification, duplicate-success and
amount-mismatch webhook handling, pending invoice reuse rules, and
RUB-only invoice currency. Extend the registry, wiring, label, and
env-isolation suites with the new provider.
2026-06-10 23:32:30 +03:00
3252a8 bd1cfd1d63 feat(webapp): expose LAVA settings in admin UI and demo
Add RU/EN admin locale strings for the LAVA settings subsection and
field labels, regenerate the demo settings manifest snapshot, and list
LAVA among the demo dataset payment methods.
2026-06-10 23:32:20 +03:00
3252a8 bb844869ee 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.
2026-06-10 23:32:09 +03:00
20 changed files with 1879 additions and 7 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
- Web App / Mini App с входом через Telegram или email;
- встроенные инструкции установки в Mini App: личный экран `/install` и публичная ссылка `/s/<token>` для передачи инструкции;
- пробный период, промокоды и реферальная программа;
- оплата через YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay, Heleket, PayKilla и Telegram Stars;
- оплата через YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay, Heleket, PayKilla, LAVA и Telegram Stars;
- тикеты поддержки в Web App и внешняя ссылка на поддержку;
- раздел "Мои устройства" при включенном `MY_DEVICES_SECTION_ENABLED`.
@@ -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 ─────────────────────────────────────────────────────
+877
View File
@@ -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/",
)
+13 -1
View File
@@ -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,
)
@@ -232,6 +232,7 @@ LOCALE_GROUPS = [
"admin_settings_field_cryptopay_",
"admin_settings_field_wata_",
"admin_settings_field_heleket_",
"admin_settings_field_lava_",
),
},
{
@@ -13,6 +13,7 @@ class PaymentContextMixin:
"platega": "Platega",
"severpay": "SeverPay",
"wata": "Wata",
"lava": "LAVA",
"cryptopay": "Crypto Pay",
"paykilla": "PayKilla",
"telegram_stars": "Telegram Stars",
+2 -1
View File
@@ -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:
+22 -1
View File
@@ -219,7 +219,7 @@ proxy/Docker gateway и может отклонить валидный webhook.
| Переменная | Назначение |
| --- | --- |
| `PAYMENT_METHODS_ORDER` | Порядок кнопок оплаты: `severpay,wata,freekassa,platega,yookassa,stars,cryptopay,heleket,paykilla`. |
| `PAYMENT_METHODS_ORDER` | Порядок кнопок оплаты: `severpay,wata,freekassa,platega,yookassa,stars,cryptopay,heleket,paykilla,lava`. |
| `SUBSCRIPTION_PURCHASE_DESCRIPTION_ENABLED` | Показывать описание подписки перед выбором срока. |
| `SUBSCRIPTION_PURCHASE_DESCRIPTION_RU` / `SUBSCRIPTION_PURCHASE_DESCRIPTION_EN` | Локализованное описание подписки. |
| `PAYMENT_REQUEST_TIMEOUT_SECONDS` | Общий таймаут одного API-запроса к платёжному провайдеру, в секундах. По умолчанию `20`. |
@@ -237,6 +237,7 @@ proxy/Docker gateway и может отклонить валидный webhook.
| `CRYPTOPAY_ENABLED` | Включает CryptoPay. |
| `HELEKET_ENABLED` | Включает Heleket. |
| `PAYKILLA_ENABLED` | Включает PayKilla. |
| `LAVA_ENABLED` | Включает LAVA. |
Конкретные ключи отображения:
@@ -301,6 +302,12 @@ PAYMENT_PAYKILLA_WEBAPP_ICON
PAYMENT_PAYKILLA_TELEGRAM_LABEL_RU
PAYMENT_PAYKILLA_TELEGRAM_LABEL_EN
PAYMENT_PAYKILLA_TELEGRAM_EMOJI
PAYMENT_LAVA_WEBAPP_LABEL_RU
PAYMENT_LAVA_WEBAPP_LABEL_EN
PAYMENT_LAVA_WEBAPP_ICON
PAYMENT_LAVA_TELEGRAM_LABEL_RU
PAYMENT_LAVA_TELEGRAM_LABEL_EN
PAYMENT_LAVA_TELEGRAM_EMOJI
```
### YooKassa
@@ -416,6 +423,20 @@ Webhook настраивается в PayKilla Dashboard: **Settings -> Webhooks
| `PAYKILLA_WEBHOOK_URL` | Точный публичный webhook URL для проверки подписи, если он отличается от `WEBHOOK_BASE_URL` + `/webhook/paykilla`. |
| `PAYKILLA_TRUSTED_IPS` | Необязательный список доверенных IP webhook-источников. |
### LAVA
Счета LAVA Business выставляются только в рублях. Исходящие запросы подписываются HMAC-SHA256 от raw body (заголовок `Signature`), webhook проверяется по заголовку `Authorization`.
| Переменная | Назначение |
| --- | --- |
| `LAVA_BASE_URL` | Базовый URL API, по умолчанию `https://api.lava.ru`. |
| `LAVA_SHOP_ID` | ID магазина в LAVA Business. |
| `LAVA_SECRET_KEY` | Секретный ключ магазина для подписи исходящих API-запросов. |
| `LAVA_WEBHOOK_SECRET` | Дополнительный ключ магазина для проверки подписи webhook; если пусто, используется `LAVA_SECRET_KEY`. |
| `LAVA_RETURN_URL` | URL возврата после оплаты (`successUrl`/`failUrl`). |
| `LAVA_LIFETIME_MINUTES` | Время жизни счета в минутах: 1..7200. |
| `LAVA_INCLUDE_SERVICES` | Способы оплаты на странице счета через запятую, например `card,sbp`. |
## Тарифы и legacy-цены
Рекомендуемый способ настройки тарифов - раздел **Система -> Тарифы** в админке. Он сохраняет JSON в `TARIFFS_CONFIG_PATH`.
+1 -1
View File
@@ -52,7 +52,7 @@
- внешний вид и доступность Web App: название, цвет, логотип и `WEBAPP_ENABLED`;
- инструкции подключения: `SUBSCRIPTION_GUIDES_ENABLED`, `SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED`, чтение конфига из Remnawave Panel, JSON-переопределение и резервный путь к файлу;
- legacy-тарифы без JSON-каталога: периоды подписки, RUB/Stars цены, реферальные бонусы и пакеты трафика;
- платежные провайдеры: включение методов, порядок кнопок, публичные параметры и секреты YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay, Heleket и Stars, а также текст и иконки кнопок оплаты;
- платежные провайдеры: включение методов, порядок кнопок, публичные параметры и секреты YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay, Heleket, LAVA и Stars, а также текст и иконки кнопок оплаты;
- пробный период, приветственный реферальный бонус, уведомления, логирование, Telegram антифлуд, поддержка, раздел устройств, лимит устройств и legacy-лимиты трафика.
Секретные поля помечены как secret и не должны использоваться для произвольного просмотра старых значений. Настройки, которых нет в manifest, остаются только в `.env` или коде.
+26
View File
@@ -44,6 +44,7 @@ reverse proxy должен прокидывать `X-Forwarded-For`, а его I
| CryptoPay | `WEBHOOK_BASE_URL` + `/webhook/cryptopay` | Указывается в настройках Crypto Bot / CryptoPay webhook. |
| Heleket | `WEBHOOK_BASE_URL` + `/webhook/heleket` | При необходимости включите `HELEKET_VERIFY_WEBHOOK_SIGNATURE` и `HELEKET_TRUSTED_IPS`. |
| PayKilla | `WEBHOOK_BASE_URL` + `/webhook/paykilla` | Указывается в PayKilla Dashboard -> Settings -> Webhooks; включите события оплаты инвойсов. |
| LAVA | `WEBHOOK_BASE_URL` + `/webhook/lava` | Передается автоматически как `hookUrl` при создании счета; можно также указать в кабинете LAVA Business. |
| Telegram Stars | Отдельный платежный webhook не нужен | Stars-события приходят через webhook Telegram-бота: `WEBHOOK_BASE_URL` + `/tg/webhook`. |
После настройки сделайте тестовый платеж и проверьте, что в логах `backend` видно входящий `POST` на нужный путь. Если провайдер сообщает, что адрес недоступен, сначала проверьте DNS/HTTPS и reverse proxy для `WEBHOOK_BASE_URL`, затем убедитесь, что путь начинается ровно с `/webhook/...` без `/api`, `/auth` и frontend-домена.
@@ -243,6 +244,31 @@ Redirect URLs в PayKilla не отправляются. Завершение п
- [PayKilla](../configuration/env-vars.md#paykilla)
## LAVA
LAVA Business используется для рублевых оплат картами и СБП через счета `https://api.lava.ru`.
Исходящие API-запросы подписываются HMAC-SHA256 от raw body, подпись передается в заголовке `Signature`. Webhook проверяется по заголовку `Authorization`: принимается подпись raw body или sorted-keys JSON (legacy PHP SDK).
### Особенности
- Счета выставляются только в рублях (`RUB`).
- `hookUrl` передается автоматически при создании счета, если задан `WEBHOOK_BASE_URL`.
- `LAVA_INCLUDE_SERVICES` ограничивает способы оплаты на странице счета, например `card,sbp`.
- При успешной оплате сумма из webhook сверяется с суммой платежа; расхождение отклоняется.
### Настройка
1. Включите `LAVA_ENABLED`.
2. Укажите `LAVA_SHOP_ID` и `LAVA_SECRET_KEY` из кабинета LAVA Business.
3. Если магазин использует отдельный дополнительный ключ для вебхуков, задайте `LAVA_WEBHOOK_SECRET`; пустое значение означает использование `LAVA_SECRET_KEY`.
4. При необходимости задайте `LAVA_LIFETIME_MINUTES` (1..7200) и `LAVA_RETURN_URL`.
5. Скопируйте URL вебхука из админ-панели и при необходимости укажите его в кабинете LAVA.
### Справочник
- [LAVA](../configuration/env-vars.md#lava)
## Telegram Stars
Telegram Stars используются напрямую и поддерживаются в legacy-ценах и JSON-каталоге тарифов.
+1
View File
@@ -69,6 +69,7 @@ Legacy-поля остаются алиасами: `prices_rub`, `conversion_rat
| Heleket | настраиваемый список `HELEKET_SUPPORTED_CURRENCIES` |
| Platega | настраиваемый список `PLATEGA_SUPPORTED_CURRENCIES` |
| SeverPay | настраиваемый список `SEVERPAY_SUPPORTED_CURRENCIES` |
| LAVA | `RUB` |
| Telegram Stars | `XTR`, отдельные Stars-цены |
В админке раздел **Система → Тарифы** показывает текущую платежную валюту и матрицу провайдеров: включен ли метод, настроен ли сервис и будет ли он доступен при выбранной валюте. Для Platega, SeverPay и Heleket список валют нужно держать в соответствии с условиями вашего мерчанта.
+5
View File
@@ -484,6 +484,11 @@ export const DEMO_DATASET = {
name: "PayKilla",
icon: "Bitcoin",
},
{
id: "lava",
name: "LAVA",
icon: "CreditCard",
},
],
referral: {
code: "DEMO0001",
@@ -670,7 +670,7 @@
"section_order": 4,
"subsection": "common",
"label": "Порядок методов оплаты",
"description": "Через запятую: severpay,freekassa,yookassa,platega,stars,cryptopay,heleket,paykilla",
"description": "Через запятую: severpay,freekassa,yookassa,platega,stars,cryptopay,heleket,paykilla,lava",
"i18n_label_key": "admin_settings_field_payment_methods_order_label",
"i18n_description_key": "admin_settings_field_payment_methods_order_description",
"i18n_subsection_key": "admin_settings_subsection_common",
@@ -4598,6 +4598,393 @@
"overridden": false,
"updated_at": null,
"webhook_base_url_configured": false
},
{
"key": "LAVA_ENABLED",
"type": "bool",
"section": "payments",
"section_order": 4,
"subsection": "LAVA",
"label": "Включена",
"description": "",
"i18n_label_key": "admin_settings_field_lava_enabled_label",
"i18n_description_key": null,
"i18n_subsection_key": "admin_settings_subsection_lava",
"i18n_placeholder_key": null,
"placeholder": "",
"optional": true,
"secret": false,
"mutually_exclusive_key": "LAVA_ADMIN_ONLY_ENABLED",
"provider_id": "lava",
"provider_label": "LAVA",
"webhook_provider_id": "lava",
"webhook_path": "/webhook/lava",
"webhook_requires_base_url": false,
"value": "",
"overridden": false,
"updated_at": null,
"webhook_base_url_configured": false
},
{
"key": "LAVA_SHOP_ID",
"type": "string",
"section": "payments",
"section_order": 4,
"subsection": "LAVA",
"label": "Shop ID",
"description": "",
"i18n_label_key": "admin_settings_field_lava_shop_id_label",
"i18n_description_key": null,
"i18n_subsection_key": "admin_settings_subsection_lava",
"i18n_placeholder_key": null,
"placeholder": "",
"optional": true,
"secret": false,
"provider_id": "lava",
"provider_label": "LAVA",
"webhook_provider_id": "lava",
"webhook_path": "/webhook/lava",
"webhook_requires_base_url": false,
"value": "",
"overridden": false,
"updated_at": null,
"webhook_base_url_configured": false
},
{
"key": "LAVA_SECRET_KEY",
"type": "string",
"section": "payments",
"section_order": 4,
"subsection": "LAVA",
"label": "Secret key",
"description": "Signs outgoing API requests (HMAC-SHA256 in the Signature header).",
"i18n_label_key": "admin_settings_field_lava_secret_key_label",
"i18n_description_key": "admin_settings_field_lava_secret_key_description",
"i18n_subsection_key": "admin_settings_subsection_lava",
"i18n_placeholder_key": null,
"placeholder": "",
"optional": true,
"secret": true,
"provider_id": "lava",
"provider_label": "LAVA",
"webhook_provider_id": "lava",
"webhook_path": "/webhook/lava",
"webhook_requires_base_url": false,
"value": "",
"overridden": false,
"updated_at": null,
"has_value": false,
"webhook_base_url_configured": false
},
{
"key": "LAVA_WEBHOOK_SECRET",
"type": "string",
"section": "payments",
"section_order": 4,
"subsection": "LAVA",
"label": "Webhook secret",
"description": "The shop's additional key used to verify webhook signatures. Leave empty to reuse the secret key.",
"i18n_label_key": "admin_settings_field_lava_webhook_secret_label",
"i18n_description_key": "admin_settings_field_lava_webhook_secret_description",
"i18n_subsection_key": "admin_settings_subsection_lava",
"i18n_placeholder_key": null,
"placeholder": "",
"optional": true,
"secret": true,
"provider_id": "lava",
"provider_label": "LAVA",
"webhook_provider_id": "lava",
"webhook_path": "/webhook/lava",
"webhook_requires_base_url": false,
"value": "",
"overridden": false,
"updated_at": null,
"has_value": false,
"webhook_base_url_configured": false
},
{
"key": "LAVA_BASE_URL",
"type": "url",
"section": "payments",
"section_order": 4,
"subsection": "LAVA",
"label": "Base URL",
"description": "",
"i18n_label_key": "admin_settings_field_lava_base_url_label",
"i18n_description_key": null,
"i18n_subsection_key": "admin_settings_subsection_lava",
"i18n_placeholder_key": "admin_settings_field_lava_base_url_placeholder",
"placeholder": "https://api.lava.ru",
"optional": true,
"secret": false,
"provider_id": "lava",
"provider_label": "LAVA",
"webhook_provider_id": "lava",
"webhook_path": "/webhook/lava",
"webhook_requires_base_url": false,
"value": "",
"overridden": false,
"updated_at": null,
"webhook_base_url_configured": false
},
{
"key": "LAVA_RETURN_URL",
"type": "url",
"section": "payments",
"section_order": 4,
"subsection": "LAVA",
"label": "Return URL",
"description": "",
"i18n_label_key": "admin_settings_field_lava_return_url_label",
"i18n_description_key": null,
"i18n_subsection_key": "admin_settings_subsection_lava",
"i18n_placeholder_key": null,
"placeholder": "",
"optional": true,
"secret": false,
"provider_id": "lava",
"provider_label": "LAVA",
"webhook_provider_id": "lava",
"webhook_path": "/webhook/lava",
"webhook_requires_base_url": false,
"value": "",
"overridden": false,
"updated_at": null,
"webhook_base_url_configured": false
},
{
"key": "LAVA_LIFETIME_MINUTES",
"type": "int",
"section": "payments",
"section_order": 4,
"subsection": "LAVA",
"label": "Payment link lifetime (minutes)",
"description": "1..7200; leave empty for the LAVA default.",
"i18n_label_key": "admin_settings_field_lava_lifetime_minutes_label",
"i18n_description_key": "admin_settings_field_lava_lifetime_minutes_description",
"i18n_subsection_key": "admin_settings_subsection_lava",
"i18n_placeholder_key": null,
"placeholder": "",
"optional": true,
"secret": false,
"min": 1,
"max": 7200,
"provider_id": "lava",
"provider_label": "LAVA",
"webhook_provider_id": "lava",
"webhook_path": "/webhook/lava",
"webhook_requires_base_url": false,
"value": "",
"overridden": false,
"updated_at": null,
"webhook_base_url_configured": false
},
{
"key": "LAVA_INCLUDE_SERVICES",
"type": "string",
"section": "payments",
"section_order": 4,
"subsection": "LAVA",
"label": "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.",
"i18n_label_key": "admin_settings_field_lava_include_services_label",
"i18n_description_key": "admin_settings_field_lava_include_services_description",
"i18n_subsection_key": "admin_settings_subsection_lava",
"i18n_placeholder_key": "admin_settings_field_lava_include_services_placeholder",
"placeholder": "card,sbp",
"optional": true,
"secret": false,
"provider_id": "lava",
"provider_label": "LAVA",
"webhook_provider_id": "lava",
"webhook_path": "/webhook/lava",
"webhook_requires_base_url": false,
"value": "",
"overridden": false,
"updated_at": null,
"webhook_base_url_configured": false
},
{
"key": "PAYMENT_LAVA_WEBAPP_LABEL_RU",
"type": "string",
"section": "payments",
"section_order": 4,
"subsection": "LAVA",
"label": "WebApp button text (RU)",
"description": "Custom Russian text shown in the Web App payment method button.",
"i18n_label_key": "admin_settings_field_payment_lava_webapp_label_ru_label",
"i18n_description_key": "admin_settings_field_payment_lava_webapp_label_ru_description",
"i18n_subsection_key": "admin_settings_subsection_lava",
"i18n_placeholder_key": "admin_settings_field_payment_lava_webapp_label_ru_placeholder",
"placeholder": "LAVA",
"optional": true,
"secret": false,
"default": "LAVA",
"provider_id": "lava",
"provider_label": "LAVA",
"webhook_provider_id": "lava",
"webhook_path": "/webhook/lava",
"webhook_requires_base_url": false,
"value": "",
"overridden": false,
"updated_at": null,
"webhook_base_url_configured": false
},
{
"key": "PAYMENT_LAVA_WEBAPP_LABEL_EN",
"type": "string",
"section": "payments",
"section_order": 4,
"subsection": "LAVA",
"label": "WebApp button text (EN)",
"description": "Custom English text shown in the Web App payment method button.",
"i18n_label_key": "admin_settings_field_payment_lava_webapp_label_en_label",
"i18n_description_key": "admin_settings_field_payment_lava_webapp_label_en_description",
"i18n_subsection_key": "admin_settings_subsection_lava",
"i18n_placeholder_key": "admin_settings_field_payment_lava_webapp_label_en_placeholder",
"placeholder": "LAVA",
"optional": true,
"secret": false,
"default": "LAVA",
"provider_id": "lava",
"provider_label": "LAVA",
"webhook_provider_id": "lava",
"webhook_path": "/webhook/lava",
"webhook_requires_base_url": false,
"value": "",
"overridden": false,
"updated_at": null,
"webhook_base_url_configured": false
},
{
"key": "PAYMENT_LAVA_WEBAPP_ICON",
"type": "icon",
"section": "payments",
"section_order": 4,
"subsection": "LAVA",
"label": "WebApp button icon",
"description": "Lucide icon name rendered inside the Web App payment method button.",
"i18n_label_key": "admin_settings_field_payment_lava_webapp_icon_label",
"i18n_description_key": "admin_settings_field_payment_lava_webapp_icon_description",
"i18n_subsection_key": "admin_settings_subsection_lava",
"i18n_placeholder_key": "admin_settings_field_payment_lava_webapp_icon_placeholder",
"placeholder": "CreditCard",
"optional": true,
"secret": false,
"default": "CreditCard",
"provider_id": "lava",
"provider_label": "LAVA",
"webhook_provider_id": "lava",
"webhook_path": "/webhook/lava",
"webhook_requires_base_url": false,
"value": "",
"overridden": false,
"updated_at": null,
"webhook_base_url_configured": false
},
{
"key": "PAYMENT_LAVA_TELEGRAM_LABEL_RU",
"type": "string",
"section": "payments",
"section_order": 4,
"subsection": "LAVA",
"label": "Telegram button text (RU)",
"description": "Custom Russian text shown in Telegram bot payment buttons.",
"i18n_label_key": "admin_settings_field_payment_lava_telegram_label_ru_label",
"i18n_description_key": "admin_settings_field_payment_lava_telegram_label_ru_description",
"i18n_subsection_key": "admin_settings_subsection_lava",
"i18n_placeholder_key": "admin_settings_field_payment_lava_telegram_label_ru_placeholder",
"placeholder": "LAVA",
"optional": true,
"secret": false,
"default": "LAVA",
"provider_id": "lava",
"provider_label": "LAVA",
"webhook_provider_id": "lava",
"webhook_path": "/webhook/lava",
"webhook_requires_base_url": false,
"value": "",
"overridden": false,
"updated_at": null,
"webhook_base_url_configured": false
},
{
"key": "PAYMENT_LAVA_TELEGRAM_LABEL_EN",
"type": "string",
"section": "payments",
"section_order": 4,
"subsection": "LAVA",
"label": "Telegram button text (EN)",
"description": "Custom English text shown in Telegram bot payment buttons.",
"i18n_label_key": "admin_settings_field_payment_lava_telegram_label_en_label",
"i18n_description_key": "admin_settings_field_payment_lava_telegram_label_en_description",
"i18n_subsection_key": "admin_settings_subsection_lava",
"i18n_placeholder_key": "admin_settings_field_payment_lava_telegram_label_en_placeholder",
"placeholder": "LAVA",
"optional": true,
"secret": false,
"default": "LAVA",
"provider_id": "lava",
"provider_label": "LAVA",
"webhook_provider_id": "lava",
"webhook_path": "/webhook/lava",
"webhook_requires_base_url": false,
"value": "",
"overridden": false,
"updated_at": null,
"webhook_base_url_configured": false
},
{
"key": "PAYMENT_LAVA_TELEGRAM_EMOJI",
"type": "string",
"section": "payments",
"section_order": 4,
"subsection": "LAVA",
"label": "Telegram button emoji",
"description": "Emoji prepended to the Telegram bot payment button when customized.",
"i18n_label_key": "admin_settings_field_payment_lava_telegram_emoji_label",
"i18n_description_key": "admin_settings_field_payment_lava_telegram_emoji_description",
"i18n_subsection_key": "admin_settings_subsection_lava",
"i18n_placeholder_key": "admin_settings_field_payment_lava_telegram_emoji_placeholder",
"placeholder": "💳",
"optional": true,
"secret": false,
"default": "💳",
"provider_id": "lava",
"provider_label": "LAVA",
"webhook_provider_id": "lava",
"webhook_path": "/webhook/lava",
"webhook_requires_base_url": false,
"value": "",
"overridden": false,
"updated_at": null,
"webhook_base_url_configured": false
},
{
"key": "LAVA_ADMIN_ONLY_ENABLED",
"type": "bool",
"section": "payments",
"section_order": 4,
"subsection": "LAVA",
"label": "Only for admins",
"description": "Shows this payment method only to users from ADMIN_IDS. Webhooks and provider services remain active for admin test payments.",
"i18n_label_key": "admin_settings_provider_admin_only_label",
"i18n_description_key": "admin_settings_provider_admin_only_description",
"i18n_subsection_key": "admin_settings_subsection_lava",
"i18n_placeholder_key": null,
"placeholder": "",
"optional": true,
"secret": false,
"mutually_exclusive_key": "LAVA_ENABLED",
"provider_id": "lava",
"provider_label": "LAVA",
"webhook_provider_id": "lava",
"webhook_path": "/webhook/lava",
"webhook_requires_base_url": false,
"value": "",
"overridden": false,
"updated_at": null,
"webhook_base_url_configured": false
}
]
},
+13
View File
@@ -1220,6 +1220,7 @@
"admin_settings_subsection_wata": "Wata",
"admin_settings_subsection_heleket": "Heleket",
"admin_settings_subsection_paykilla": "PayKilla",
"admin_settings_subsection_lava": "LAVA",
"admin_settings_provider_webhook_url": "Webhook URL",
"admin_settings_provider_webhook_url_hint": "Use this URL in the provider webhook settings.",
"admin_settings_panel_webhook_url_hint": "Use this URL as WEBHOOK_URL in Remnawave Panel.",
@@ -1772,6 +1773,18 @@
"admin_settings_field_severpay_return_url_label": "SeverPay Return URL",
"admin_settings_field_severpay_lifetime_minutes_label": "SeverPay Lifetime Minutes",
"admin_settings_field_severpay_lifetime_minutes_description": "Controls the 'SeverPay Lifetime Minutes' setting in admin overrides.",
"admin_settings_field_lava_enabled_label": "Enabled",
"admin_settings_field_lava_shop_id_label": "Shop ID",
"admin_settings_field_lava_secret_key_label": "Secret key",
"admin_settings_field_lava_secret_key_description": "Signs outgoing API requests (HMAC-SHA256 in the Signature header).",
"admin_settings_field_lava_webhook_secret_label": "Webhook secret",
"admin_settings_field_lava_webhook_secret_description": "The shop's additional key used to verify webhook signatures. Leave empty to reuse the secret key.",
"admin_settings_field_lava_base_url_label": "Base URL",
"admin_settings_field_lava_return_url_label": "Return URL",
"admin_settings_field_lava_lifetime_minutes_label": "Payment link lifetime (minutes)",
"admin_settings_field_lava_lifetime_minutes_description": "1..7200; leave empty for the LAVA default.",
"admin_settings_field_lava_include_services_label": "Payment services filter",
"admin_settings_field_lava_include_services_description": "Comma-separated LAVA pay services shown on the payment page (e.g. card,sbp). Empty shows everything enabled for the shop.",
"admin_settings_field_cryptopay_enabled_label": "CryptoPay Enabled",
"admin_settings_field_cryptopay_token_label": "CryptoPay Token",
"admin_settings_field_cryptopay_network_label": "CryptoPay Network",
+13
View File
@@ -1220,6 +1220,7 @@
"admin_settings_subsection_wata": "Wata",
"admin_settings_subsection_heleket": "Heleket",
"admin_settings_subsection_paykilla": "PayKilla",
"admin_settings_subsection_lava": "LAVA",
"admin_settings_provider_webhook_url": "Webhook URL",
"admin_settings_provider_webhook_url_hint": "Укажите этот адрес в настройках вебхуков провайдера.",
"admin_settings_panel_webhook_url_hint": "Укажите этот адрес как WEBHOOK_URL в Remnawave Panel.",
@@ -1772,6 +1773,18 @@
"admin_settings_field_severpay_return_url_label": "Return URL",
"admin_settings_field_severpay_lifetime_minutes_label": "Срок жизни ссылки (мин)",
"admin_settings_field_severpay_lifetime_minutes_description": "30..4320; пусто — значение провайдера",
"admin_settings_field_lava_enabled_label": "Включена",
"admin_settings_field_lava_shop_id_label": "Shop ID",
"admin_settings_field_lava_secret_key_label": "Секретный ключ",
"admin_settings_field_lava_secret_key_description": "Подписывает исходящие запросы к API (HMAC-SHA256 в заголовке Signature).",
"admin_settings_field_lava_webhook_secret_label": "Ключ вебхуков",
"admin_settings_field_lava_webhook_secret_description": "Дополнительный ключ магазина для проверки подписи вебхуков. Пусто — используется секретный ключ.",
"admin_settings_field_lava_base_url_label": "Base URL",
"admin_settings_field_lava_return_url_label": "Return URL",
"admin_settings_field_lava_lifetime_minutes_label": "Срок жизни ссылки (мин)",
"admin_settings_field_lava_lifetime_minutes_description": "1..7200; пусто — значение провайдера",
"admin_settings_field_lava_include_services_label": "Фильтр способов оплаты",
"admin_settings_field_lava_include_services_description": "Через запятую: способы оплаты LAVA на странице счёта (например card,sbp). Пусто — все доступные магазину.",
"admin_settings_field_cryptopay_enabled_label": "Включена",
"admin_settings_field_cryptopay_token_label": "Token",
"admin_settings_field_cryptopay_network_label": "Network",
+2
View File
@@ -29,6 +29,7 @@ def _isolate_provider_env(monkeypatch):
"SEVERPAY_",
"WATA_",
"HELEKET_",
"LAVA_",
"CRYPTOPAY_",
"YOOKASSA_",
"PAYMENT_FREEKASSA_",
@@ -36,6 +37,7 @@ def _isolate_provider_env(monkeypatch):
"PAYMENT_SEVERPAY_",
"PAYMENT_WATA_",
"PAYMENT_HELEKET_",
"PAYMENT_LAVA_",
"PAYMENT_CRYPTOPAY_",
"PAYMENT_YOOKASSA_",
"PAYMENT_STARS_",
+2
View File
@@ -35,6 +35,7 @@ _PROVIDER_ENV_PREFIXES = (
"SEVERPAY_",
"WATA_",
"HELEKET_",
"LAVA_",
"CRYPTOPAY_",
"YOOKASSA_",
"STARS_",
@@ -176,6 +177,7 @@ class BuildServicesWiringTests(unittest.TestCase):
"wata_service",
"heleket_service",
"paykilla_service",
"lava_service",
}
self.assertEqual(set(services), expected_keys)
+506
View File
@@ -0,0 +1,506 @@
"""Contract tests for the LAVA Business provider.
The LAVA API accepts exactly one signature scheme for outgoing requests:
HMAC-SHA256 over the raw body bytes in the ``Signature`` HTTP header. A
body-embedded ``signature`` field (legacy PHP SDK style) is rejected with
401, so these tests pin the header form. Webhook verification is tolerant:
LAVA shops sign either the raw body or a sorted-keys re-serialization.
"""
import asyncio
import hashlib
import hmac
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock
from bot.payment_providers import lava
from bot.payment_providers.lava import LavaConfig, LavaService
def _hmac_hex(message: bytes, key: str) -> str:
return hmac.new(key.encode("utf-8"), message, hashlib.sha256).hexdigest()
def _make_service(**config_overrides) -> LavaService:
config_values = {
"ENABLED": True,
"SHOP_ID": "shop-xyz",
"SECRET_KEY": "outgoing-secret",
"WEBHOOK_SECRET": "webhook-secret",
}
config_values.update(config_overrides)
service = object.__new__(LavaService)
service.config = LavaConfig(**config_values)
service.settings = SimpleNamespace(
DEFAULT_CURRENCY_SYMBOL="RUB",
WEBHOOK_BASE_URL="https://bot.example.com",
PAYMENT_REQUEST_TIMEOUT_SECONDS=30,
)
service._default_return_url = "testbot"
return service
class _FakeResponse:
def __init__(self, status=200, payload=None):
self.status = status
self._payload = (
payload
if payload is not None
else {"status": "success", "data": {"id": "inv-1", "url": "https://pay.lava.ru/x"}}
)
async def text(self):
return json.dumps(self._payload)
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return None
def _capture_session(captured, response=None):
session = SimpleNamespace()
def post(url, data=None, headers=None):
captured["url"] = url
captured["data"] = data
captured["headers"] = headers
return response or _FakeResponse()
session.post = post
return session
class _FakeWebhookRequest:
def __init__(self, payload, signature=None, secret="webhook-secret"):
self._body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
self.headers = {
"Authorization": signature if signature is not None else _hmac_hex(self._body, secret)
}
async def read(self):
return self._body
class _FakeDbSession:
def __call__(self):
return self
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def commit(self):
pass
async def rollback(self):
pass
# ---------------------------------------------------------------------------
# Outgoing request contract
# ---------------------------------------------------------------------------
def test_create_payment_signs_raw_body_in_signature_header(monkeypatch):
service = _make_service()
captured = {}
monkeypatch.setattr(service, "_get_session", AsyncMock(return_value=_capture_session(captured)))
success, data = asyncio.run(
service.create_payment(payment_db_id=77, amount=150.0, currency="RUB")
)
assert success
assert data == {"id": "inv-1", "url": "https://pay.lava.ru/x"}
assert captured["url"] == "https://api.lava.ru/business/invoice/create"
# Signature travels in the header and matches the exact bytes sent.
assert captured["headers"]["Signature"] == _hmac_hex(captured["data"], "outgoing-secret")
body = json.loads(captured["data"])
assert "signature" not in body
assert body["sum"] == 150.0
assert body["orderId"] == "77"
assert body["shopId"] == "shop-xyz"
assert body["hookUrl"] == "https://bot.example.com/webhook/lava"
def test_create_payment_keeps_payload_key_order(monkeypatch):
# A sorted-keys re-serialization would diverge from the signed raw body,
# so the wire format must preserve insertion order ("sum" before "hookUrl").
service = _make_service()
captured = {}
monkeypatch.setattr(service, "_get_session", AsyncMock(return_value=_capture_session(captured)))
asyncio.run(service.create_payment(payment_db_id=1, amount=10.0, currency="RUB"))
text = captured["data"].decode("utf-8")
assert text.find('"sum"') < text.find('"hookUrl"')
def test_create_payment_rejects_non_rub_currency(monkeypatch):
service = _make_service()
monkeypatch.setattr(
service,
"_get_session",
AsyncMock(side_effect=AssertionError("must not reach the API for unsupported currency")),
)
success, data = asyncio.run(
service.create_payment(payment_db_id=1, amount=10.0, currency="USD")
)
assert not success
assert data["message"] == "unsupported_currency"
def test_create_payment_surfaces_api_error(monkeypatch):
service = _make_service()
captured = {}
response = _FakeResponse(
status=401, payload={"status": "error", "error": "Invalid signature", "code": 401}
)
monkeypatch.setattr(
service,
"_get_session",
AsyncMock(return_value=_capture_session(captured, response=response)),
)
success, data = asyncio.run(
service.create_payment(payment_db_id=1, amount=10.0, currency="RUB")
)
assert not success
assert data["message"] == "Invalid signature"
def test_create_payment_passes_optional_invoice_fields(monkeypatch):
service = _make_service(LIFETIME_MINUTES=90, INCLUDE_SERVICES="card, sbp")
captured = {}
monkeypatch.setattr(service, "_get_session", AsyncMock(return_value=_capture_session(captured)))
asyncio.run(
service.create_payment(
payment_db_id=5, amount=10.0, currency="RUB", description="Subscription 1m"
)
)
body = json.loads(captured["data"])
assert body["expire"] == 90
assert body["comment"] == "Subscription 1m"
assert body["includeService"] == ["card", "sbp"]
def test_service_is_unconfigured_without_credentials():
assert not _make_service(SHOP_ID=None).configured
assert not _make_service(SECRET_KEY=None).configured
assert not _make_service(ENABLED=False).configured
assert _make_service().configured
assert _make_service(ENABLED=False, ADMIN_ONLY_ENABLED=True).configured
# ---------------------------------------------------------------------------
# Webhook signature verification
# ---------------------------------------------------------------------------
def test_webhook_signature_accepts_raw_body_hmac():
service = _make_service()
body = b'{"order_id":"1","status":"success"}'
assert service.verify_webhook_signature(body, _hmac_hex(body, "webhook-secret"))
def test_webhook_signature_accepts_canonical_sorted_json_hmac():
# Legacy PHP-SDK shops sign a sorted-keys re-serialization instead of the raw body.
service = _make_service()
body = b'{"status":"success","order_id":"1"}'
canonical = json.dumps(
{"order_id": "1", "status": "success"}, sort_keys=True, separators=(",", ":")
)
signature = _hmac_hex(canonical.encode("utf-8"), "webhook-secret")
assert service.verify_webhook_signature(body, signature)
def test_webhook_signature_rejects_wrong_or_empty_signature():
service = _make_service()
body = b'{"order_id":"1","status":"success"}'
assert not service.verify_webhook_signature(body, "deadbeef" * 8)
assert not service.verify_webhook_signature(body, "")
def test_webhook_signature_falls_back_to_secret_key():
service = _make_service(WEBHOOK_SECRET=None)
body = b'{"order_id":"1"}'
assert service.verify_webhook_signature(body, _hmac_hex(body, "outgoing-secret"))
def test_webhook_signature_fails_closed_without_any_secret():
service = _make_service(SECRET_KEY=None, WEBHOOK_SECRET=None)
body = b'{"order_id":"1"}'
assert not service.verify_webhook_signature(body, _hmac_hex(body, ""))
# ---------------------------------------------------------------------------
# Webhook route behaviour
# ---------------------------------------------------------------------------
def _webhook_service(session, payment, monkeypatch, **overrides):
monkeypatch.setattr(
lava,
"lookup_payment_by_order_or_provider_id",
AsyncMock(return_value=payment),
)
service = SimpleNamespace(
configured=True,
verify_webhook_signature=lambda _raw, _sig: True,
async_session_factory=session,
settings=SimpleNamespace(traffic_sale_mode=False),
bot=SimpleNamespace(),
i18n=SimpleNamespace(),
subscription_service=SimpleNamespace(),
referral_service=SimpleNamespace(),
)
for key, value in overrides.items():
setattr(service, key, value)
return service
def test_webhook_invalid_signature_is_rejected():
service = _make_service()
request = _FakeWebhookRequest({"order_id": "1", "status": "success"}, signature="f" * 64)
response = asyncio.run(service.webhook_route(request))
assert response.status == 403
def test_webhook_duplicate_success_does_not_finalize_again(monkeypatch):
session = _FakeDbSession()
payment = SimpleNamespace(
payment_id=88,
user_id=42,
status="succeeded",
sale_mode="subscription",
purchased_hwid_devices=None,
purchased_gb=None,
subscription_duration_months=1,
amount=150.0,
currency="RUB",
user=None,
)
service = _webhook_service(session, payment, monkeypatch)
monkeypatch.setattr(
lava.payment_dal,
"update_provider_payment_and_status",
AsyncMock(side_effect=AssertionError("duplicate webhook must not update payment")),
)
monkeypatch.setattr(
lava,
"finalize_successful_payment",
AsyncMock(side_effect=AssertionError("duplicate webhook must not finalize")),
)
response = asyncio.run(
LavaService.webhook_route(
service,
_FakeWebhookRequest({"invoice_id": "inv-1", "order_id": "88", "status": "success"}),
)
)
assert response.status == 200
def test_webhook_success_finalizes_payment(monkeypatch):
session = _FakeDbSession()
payment = SimpleNamespace(
payment_id=88,
user_id=42,
status="pending_lava",
sale_mode="subscription",
purchased_hwid_devices=None,
purchased_gb=None,
subscription_duration_months=1,
amount=150.0,
currency="RUB",
user=None,
)
service = _webhook_service(session, payment, monkeypatch)
update_mock = AsyncMock()
finalize_mock = AsyncMock(return_value=SimpleNamespace())
monkeypatch.setattr(lava.payment_dal, "update_provider_payment_and_status", update_mock)
monkeypatch.setattr(lava, "finalize_successful_payment", finalize_mock)
response = asyncio.run(
LavaService.webhook_route(
service,
_FakeWebhookRequest(
{"invoice_id": "inv-1", "order_id": "88", "status": "success", "amount": 150.0}
),
)
)
assert response.status == 200
update_mock.assert_awaited_once_with(session, 88, "inv-1", "succeeded")
finalize_mock.assert_awaited_once()
def test_webhook_success_with_amount_mismatch_is_rejected(monkeypatch):
session = _FakeDbSession()
payment = SimpleNamespace(
payment_id=88,
user_id=42,
status="pending_lava",
sale_mode="subscription",
purchased_hwid_devices=None,
purchased_gb=None,
subscription_duration_months=1,
amount=150.0,
currency="RUB",
user=None,
)
service = _webhook_service(session, payment, monkeypatch)
monkeypatch.setattr(
lava.payment_dal,
"update_provider_payment_and_status",
AsyncMock(side_effect=AssertionError("mismatched amount must not update payment")),
)
monkeypatch.setattr(
lava,
"finalize_successful_payment",
AsyncMock(side_effect=AssertionError("mismatched amount must not finalize")),
)
response = asyncio.run(
LavaService.webhook_route(
service,
_FakeWebhookRequest(
{"invoice_id": "inv-1", "order_id": "88", "status": "success", "amount": 9999}
),
)
)
assert response.status == 400
def test_webhook_failed_status_marks_payment_failed(monkeypatch):
session = _FakeDbSession()
payment = SimpleNamespace(
payment_id=88,
user_id=42,
status="pending_lava",
sale_mode="subscription",
purchased_hwid_devices=None,
purchased_gb=None,
subscription_duration_months=1,
amount=150.0,
currency="RUB",
user=None,
)
service = _webhook_service(session, payment, monkeypatch)
update_mock = AsyncMock()
notify_mock = AsyncMock()
monkeypatch.setattr(lava.payment_dal, "update_provider_payment_and_status", update_mock)
monkeypatch.setattr(lava, "notify_user_payment_failed", notify_mock)
monkeypatch.setattr(
lava,
"finalize_successful_payment",
AsyncMock(side_effect=AssertionError("failed webhook must not finalize")),
)
response = asyncio.run(
LavaService.webhook_route(
service,
_FakeWebhookRequest({"invoice_id": "inv-1", "order_id": "88", "status": "expired"}),
)
)
assert response.status == 200
update_mock.assert_awaited_once_with(session, 88, "inv-1", "failed")
notify_mock.assert_awaited_once()
def test_webhook_unknown_payment_returns_404(monkeypatch):
session = _FakeDbSession()
service = _webhook_service(session, None, monkeypatch)
response = asyncio.run(
LavaService.webhook_route(
service,
_FakeWebhookRequest({"invoice_id": "inv-x", "order_id": "404", "status": "success"}),
)
)
assert response.status == 404
# ---------------------------------------------------------------------------
# Pending payment reuse
# ---------------------------------------------------------------------------
def test_reuse_returns_url_for_pending_invoice(monkeypatch):
service = _make_service()
payment = SimpleNamespace(
payment_id=88,
provider_payment_id="inv-1",
provider_payment_url="https://pay.lava.ru/x",
)
monkeypatch.setattr(
service,
"get_invoice_status",
AsyncMock(return_value=(True, {"id": "inv-1", "order_id": "88", "status": "created"})),
)
assert asyncio.run(service.try_reuse_pending_payment(payment)) == "https://pay.lava.ru/x"
def test_reuse_rejects_terminal_or_foreign_invoices(monkeypatch):
service = _make_service()
payment = SimpleNamespace(
payment_id=88,
provider_payment_id="inv-1",
provider_payment_url="https://pay.lava.ru/x",
)
monkeypatch.setattr(
service,
"get_invoice_status",
AsyncMock(return_value=(True, {"id": "inv-1", "order_id": "88", "status": "expired"})),
)
assert asyncio.run(service.try_reuse_pending_payment(payment)) is None
monkeypatch.setattr(
service,
"get_invoice_status",
AsyncMock(return_value=(True, {"id": "other", "order_id": "88", "status": "created"})),
)
assert asyncio.run(service.try_reuse_pending_payment(payment)) is None
monkeypatch.setattr(
service,
"get_invoice_status",
AsyncMock(return_value=(True, {"id": "inv-1", "order_id": "99", "status": "created"})),
)
assert asyncio.run(service.try_reuse_pending_payment(payment)) is None
def test_reuse_requires_stored_url_and_id():
service = _make_service()
assert (
asyncio.run(
service.try_reuse_pending_payment(
SimpleNamespace(payment_id=1, provider_payment_id="", provider_payment_url="")
)
)
is None
)
+3
View File
@@ -66,6 +66,7 @@ _PROVIDER_MODULES = {
"wata": "WataService",
"heleket": "HeleketService",
"paykilla": "PaykillaService",
"lava": "LavaService",
}
@@ -128,6 +129,7 @@ def test_service_keys_and_statuses_come_from_provider_specs():
"cryptopay_service",
"heleket_service",
"paykilla_service",
"lava_service",
}
assert set(pending_statuses()) >= {
"pending",
@@ -140,6 +142,7 @@ def test_service_keys_and_statuses_come_from_provider_specs():
"pending_stars",
"pending_heleket",
"pending_paykilla",
"pending_lava",
}
+1
View File
@@ -26,6 +26,7 @@ _PROVIDERS_FROM_CALLERS = {
"platega",
"severpay",
"wata",
"lava",
"cryptopay",
"paykilla",
"telegram_stars",