Compare commits
7
Commits
main
..
feature/lava
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc4da78fc4 | ||
|
|
2074e934ce | ||
|
|
bd1cfd1d63 | ||
|
|
bb844869ee | ||
|
|
1d43606446 | ||
|
|
5ca1ecfce6 | ||
|
|
86ac925b19 |
@@ -1,7 +1,7 @@
|
|||||||
name: Dev images
|
name: Dev images
|
||||||
|
|
||||||
# On every push to the dev branch, build all three images and push them to
|
# On every push to the dev branch, build all three images and push them to
|
||||||
# GHCR tagged `dev`. Docker Hub dev images are published by GitLab CI.
|
# GHCR and Docker Hub tagged `dev`.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -22,5 +22,4 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
push: true
|
push: true
|
||||||
tag_mode: dev
|
tag_mode: dev
|
||||||
publish_dockerhub: false
|
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
stages:
|
|
||||||
- docker
|
|
||||||
|
|
||||||
workflow:
|
|
||||||
rules:
|
|
||||||
- if: '$CI_COMMIT_BRANCH == "dev"'
|
|
||||||
- when: never
|
|
||||||
|
|
||||||
variables:
|
|
||||||
DOCKER_BUILDKIT: "1"
|
|
||||||
DOCKER_DRIVER: overlay2
|
|
||||||
DOCKER_HOST: tcp://docker:2375
|
|
||||||
DOCKER_TLS_CERTDIR: ""
|
|
||||||
GIT_DEPTH: "0"
|
|
||||||
|
|
||||||
docker-dev:
|
|
||||||
stage: docker
|
|
||||||
image: docker:27.5.1
|
|
||||||
services:
|
|
||||||
- name: docker:27.5.1-dind
|
|
||||||
alias: docker
|
|
||||||
interruptible: true
|
|
||||||
parallel:
|
|
||||||
matrix:
|
|
||||||
- TARGET: backend
|
|
||||||
IMAGE: remnawave-minishop-backend
|
|
||||||
- TARGET: worker
|
|
||||||
IMAGE: remnawave-minishop-worker
|
|
||||||
- TARGET: frontend
|
|
||||||
IMAGE: remnawave-minishop-frontend
|
|
||||||
before_script:
|
|
||||||
- apk add --no-cache git
|
|
||||||
- test -n "$DOCKERHUB_USERNAME"
|
|
||||||
- test -n "$DOCKERHUB_TOKEN"
|
|
||||||
- echo "$DOCKERHUB_TOKEN" | docker login --username "$DOCKERHUB_USERNAME" --password-stdin
|
|
||||||
- docker buildx create --name gitlab-builder --driver docker-container --use
|
|
||||||
- docker buildx inspect --bootstrap
|
|
||||||
script:
|
|
||||||
- git fetch origin dev --tags
|
|
||||||
- |
|
|
||||||
if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/dev)" ]; then
|
|
||||||
echo "A newer dev commit exists; skipping Docker Hub build for $IMAGE."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
- |
|
|
||||||
provenance="${REMNAWAVE_MINISHOP_BUILD_PROVENANCE:-}"
|
|
||||||
if [ -z "$provenance" ]; then
|
|
||||||
dockerhub_owner="$(printf '%s' "${DOCKERHUB_USERNAME:-}" | tr '[:upper:]' '[:lower:]')"
|
|
||||||
if [ "${CI_PROJECT_PATH:-}" = "3252a8/remnawave-minishop" ] || [ "$dockerhub_owner" = "3252a8" ]; then
|
|
||||||
provenance="official"
|
|
||||||
else
|
|
||||||
provenance="custom"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
docker buildx build \
|
|
||||||
--load \
|
|
||||||
--platform linux/amd64 \
|
|
||||||
--file deploy/docker/Dockerfile \
|
|
||||||
--target "$TARGET" \
|
|
||||||
--build-arg "CI_COMMIT_REF_NAME=$CI_COMMIT_REF_NAME" \
|
|
||||||
--build-arg "REMNAWAVE_MINISHOP_BUILD_PROVENANCE=$provenance" \
|
|
||||||
--build-arg "BUILDKIT_INLINE_CACHE=1" \
|
|
||||||
--cache-from "type=registry,ref=$DOCKERHUB_USERNAME/$IMAGE:dev" \
|
|
||||||
--tag "$DOCKERHUB_USERNAME/$IMAGE:dev" \
|
|
||||||
.
|
|
||||||
- git fetch origin dev --tags
|
|
||||||
- |
|
|
||||||
if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/dev)" ]; then
|
|
||||||
echo "A newer dev commit exists; skipping Docker Hub push for $IMAGE."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
- docker push "$DOCKERHUB_USERNAME/$IMAGE:dev"
|
|
||||||
@@ -16,7 +16,7 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
|
|||||||
- Web App / Mini App с входом через Telegram или email;
|
- Web App / Mini App с входом через Telegram или email;
|
||||||
- встроенные инструкции установки в Mini App: личный экран `/install` и публичная ссылка `/s/<token>` для передачи инструкции;
|
- встроенные инструкции установки в 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 и внешняя ссылка на поддержку;
|
- тикеты поддержки в Web App и внешняя ссылка на поддержку;
|
||||||
- раздел "Мои устройства" при включенном `MY_DEVICES_SECTION_ENABLED`.
|
- раздел "Мои устройства" при включенном `MY_DEVICES_SECTION_ENABLED`.
|
||||||
|
|
||||||
@@ -139,15 +139,3 @@ IMAGE_TAG=3.1.0 docker compose up -d
|
|||||||
- `docker.io/3252a8/remnawave-minishop-backend`
|
- `docker.io/3252a8/remnawave-minishop-backend`
|
||||||
- `docker.io/3252a8/remnawave-minishop-worker`
|
- `docker.io/3252a8/remnawave-minishop-worker`
|
||||||
- `docker.io/3252a8/remnawave-minishop-frontend`
|
- `docker.io/3252a8/remnawave-minishop-frontend`
|
||||||
|
|
||||||
Сборка и публикация сразу в GHCR и Docker Hub:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker login ghcr.io
|
|
||||||
docker login docker.io
|
|
||||||
IMAGE_TAG=v3.4.3 bash scripts/docker-build-push-images.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Поддержать проект
|
|
||||||
|
|
||||||
- Crypto: `USDT/Other ERC-20 0xeD506D44aae634fEc0E01C8835744fBedb7B2a44 (Ethereum/Polygon/Gnosis)`
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ from bot.services.settings_override_service import (
|
|||||||
current_value,
|
current_value,
|
||||||
update_overrides,
|
update_overrides,
|
||||||
)
|
)
|
||||||
from bot.utils import MessageContent, send_message_via_queue, SUPPORTED_PARAMS
|
from bot.utils import MessageContent, send_message_via_queue
|
||||||
from bot.utils.message_queue import get_queue_manager
|
from bot.utils.message_queue import get_queue_manager
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
from config.tariffs_config import TariffsConfig, default_payment_currency_code_for_settings
|
from config.tariffs_config import TariffsConfig, default_payment_currency_code_for_settings
|
||||||
|
|||||||
@@ -7,11 +7,6 @@ from collections import defaultdict
|
|||||||
|
|
||||||
from bot.utils.ttl_cache import AsyncTTLCache
|
from bot.utils.ttl_cache import AsyncTTLCache
|
||||||
|
|
||||||
import tempfile
|
|
||||||
import os
|
|
||||||
from aiogram.types import InputFile, Message
|
|
||||||
from aiogram.exceptions import TelegramBadRequest
|
|
||||||
|
|
||||||
|
|
||||||
BROADCAST_TARGET_ACTIVE_NEVER_CONNECTED = "active_never_connected"
|
BROADCAST_TARGET_ACTIVE_NEVER_CONNECTED = "active_never_connected"
|
||||||
BROADCAST_TARGETS = {
|
BROADCAST_TARGETS = {
|
||||||
@@ -172,31 +167,10 @@ async def admin_broadcast_route(request: web.Request) -> web.Response:
|
|||||||
payload = await _read_json(request)
|
payload = await _read_json(request)
|
||||||
text = str(payload.get("text") or "").strip()
|
text = str(payload.get("text") or "").strip()
|
||||||
target = str(payload.get("target") or "all").strip().lower()
|
target = str(payload.get("target") or "all").strip().lower()
|
||||||
|
|
||||||
# --- НОВЫЕ ПОЛЯ ДЛЯ МЕДИА ---
|
|
||||||
media_type = payload.get("media_type") # например "photo", "video", "document" и т.д.
|
|
||||||
media_file_id = payload.get("media_file_id") # file_id или URL (строка)
|
|
||||||
# ---------------------------
|
|
||||||
|
|
||||||
if target not in BROADCAST_TARGETS:
|
|
||||||
target = "all"
|
|
||||||
|
|
||||||
# --- ЛОГИКА ФОРМИРОВАНИЯ MessageContent ---
|
|
||||||
if media_type and media_file_id:
|
|
||||||
# Отправляем медиа: текст становится подписью (caption)
|
|
||||||
if media_type not in SUPPORTED_PARAMS:
|
|
||||||
return _error(400, "unsupported_media_type")
|
|
||||||
content = MessageContent(
|
|
||||||
content_type=media_type,
|
|
||||||
file_id=media_file_id,
|
|
||||||
text=text, # будет использован как caption
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Текстовое сообщение
|
|
||||||
if not text:
|
if not text:
|
||||||
return _error(400, "empty_text")
|
return _error(400, "empty_text")
|
||||||
content = MessageContent(content_type="text", text=text)
|
if target not in BROADCAST_TARGETS:
|
||||||
# --------------------------------------
|
target = "all"
|
||||||
|
|
||||||
queue_manager = get_queue_manager()
|
queue_manager = get_queue_manager()
|
||||||
if not queue_manager:
|
if not queue_manager:
|
||||||
@@ -227,11 +201,10 @@ async def admin_broadcast_route(request: web.Request) -> web.Response:
|
|||||||
failed = 0
|
failed = 0
|
||||||
for uid in user_ids:
|
for uid in user_ids:
|
||||||
try:
|
try:
|
||||||
# Передаём content (уже с нужным типом), а остальные параметры остаются
|
|
||||||
await send_message_via_queue(
|
await send_message_via_queue(
|
||||||
queue_manager,
|
queue_manager,
|
||||||
int(uid),
|
int(uid),
|
||||||
content,
|
MessageContent(content_type="text", text=text),
|
||||||
parse_mode="HTML",
|
parse_mode="HTML",
|
||||||
disable_web_page_preview=True,
|
disable_web_page_preview=True,
|
||||||
)
|
)
|
||||||
@@ -245,13 +218,14 @@ async def admin_broadcast_route(request: web.Request) -> web.Response:
|
|||||||
{
|
{
|
||||||
"user_id": actor_id,
|
"user_id": actor_id,
|
||||||
"event_type": "admin_broadcast_webapp",
|
"event_type": "admin_broadcast_webapp",
|
||||||
"content": f"target={target} sent={sent} failed={failed} "
|
"content": f"target={target} sent={sent} failed={failed} text={text[:120]}",
|
||||||
f"type={content.content_type} text={text[:120]}",
|
"is_admin_event": True,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
return _ok({"queued": sent, "failed": failed, "target": target})
|
return _ok({"queued": sent, "failed": failed, "target": target})
|
||||||
|
|
||||||
|
|
||||||
async def admin_broadcast_audience_counts_route(request: web.Request) -> web.Response:
|
async def admin_broadcast_audience_counts_route(request: web.Request) -> web.Response:
|
||||||
"""Return how many users each broadcast audience currently resolves to."""
|
"""Return how many users each broadcast audience currently resolves to."""
|
||||||
_require_admin_user_id(request)
|
_require_admin_user_id(request)
|
||||||
@@ -266,67 +240,3 @@ async def admin_broadcast_audience_counts_route(request: web.Request) -> web.Res
|
|||||||
)
|
)
|
||||||
|
|
||||||
return _ok({"counts": counts})
|
return _ok({"counts": counts})
|
||||||
|
|
||||||
|
|
||||||
async def admin_upload_media_route(request: web.Request) -> web.Response:
|
|
||||||
"""
|
|
||||||
Эндпоинт для загрузки медиафайла администратором.
|
|
||||||
Принимает multipart/form-data с полем 'file'.
|
|
||||||
Возвращает file_id, полученный от Telegram Bot.
|
|
||||||
"""
|
|
||||||
actor_id = _require_admin_user_id(request)
|
|
||||||
|
|
||||||
# Проверяем, что это multipart-запрос
|
|
||||||
if request.content_type and not request.content_type.startswith("multipart/form-data"):
|
|
||||||
return _error(400, "invalid_content_type")
|
|
||||||
|
|
||||||
reader = await request.multipart()
|
|
||||||
field = await reader.next()
|
|
||||||
if field is None or field.name != "file":
|
|
||||||
return _error(400, "file_required")
|
|
||||||
|
|
||||||
# Сохраняем файл во временный файл
|
|
||||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".tmp") as tmp:
|
|
||||||
while True:
|
|
||||||
chunk = await field.read_chunk()
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
tmp.write(chunk)
|
|
||||||
tmp_path = tmp.name
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Получаем бота из app
|
|
||||||
bot = request.app.get("bot")
|
|
||||||
if bot is None:
|
|
||||||
return _error(503, "bot_unavailable")
|
|
||||||
|
|
||||||
# Отправляем файл в личный чат администратора, чтобы получить file_id
|
|
||||||
# Используем send_document, так как он подходит для любых файлов
|
|
||||||
with open(tmp_path, "rb") as f:
|
|
||||||
input_file = InputFile(f)
|
|
||||||
sent_msg: Message = await bot.send_document(
|
|
||||||
chat_id=actor_id,
|
|
||||||
document=input_file,
|
|
||||||
# Не отправляем лишний текст, чтобы не привлекать внимание
|
|
||||||
)
|
|
||||||
|
|
||||||
file_id = sent_msg.document.file_id
|
|
||||||
|
|
||||||
# Удаляем сообщение, чтобы не засорять чат
|
|
||||||
await bot.delete_message(chat_id=actor_id, message_id=sent_msg.message_id)
|
|
||||||
|
|
||||||
# Возвращаем file_id
|
|
||||||
return _ok({"file_id": file_id})
|
|
||||||
|
|
||||||
except TelegramBadRequest as e:
|
|
||||||
logger.warning("Failed to upload media to Telegram: %s", e)
|
|
||||||
return _error(400, f"telegram_error: {str(e)}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Unexpected error during media upload: %s", e, exc_info=True)
|
|
||||||
return _error(500, "upload_failed")
|
|
||||||
finally:
|
|
||||||
# Удаляем временный файл
|
|
||||||
try:
|
|
||||||
os.unlink(tmp_path)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
@@ -64,8 +64,6 @@ def setup_admin_routes(app: web.Application) -> None:
|
|||||||
|
|
||||||
router.add_get("/api/admin/broadcast/audience-counts", admin_broadcast_audience_counts_route)
|
router.add_get("/api/admin/broadcast/audience-counts", admin_broadcast_audience_counts_route)
|
||||||
router.add_post("/api/admin/broadcast", admin_broadcast_route)
|
router.add_post("/api/admin/broadcast", admin_broadcast_route)
|
||||||
router.add_post("/api/admin/upload-media", admin_upload_media_route)
|
|
||||||
|
|
||||||
router.add_post("/api/admin/sync", admin_sync_route)
|
router.add_post("/api/admin/sync", admin_sync_route)
|
||||||
|
|
||||||
router.add_get("/api/admin/ads", admin_ads_list_route)
|
router.add_get("/api/admin/ads", admin_ads_list_route)
|
||||||
|
|||||||
@@ -379,7 +379,7 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
|||||||
"string",
|
"string",
|
||||||
"payments",
|
"payments",
|
||||||
"Порядок методов оплаты",
|
"Порядок методов оплаты",
|
||||||
"Через запятую: severpay,freekassa,yookassa,platega,stars,cryptopay,heleket,paykilla",
|
"Через запятую: severpay,freekassa,yookassa,platega,stars,cryptopay,heleket,paykilla,lava",
|
||||||
subsection="common",
|
subsection="common",
|
||||||
),
|
),
|
||||||
# ─── Trial ─────────────────────────────────────────────────────
|
# ─── Trial ─────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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/",
|
||||||
|
)
|
||||||
@@ -2,7 +2,18 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any, Dict, Iterable, List, Mapping, Optional
|
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 (
|
from .base import (
|
||||||
PaymentProviderPresentation,
|
PaymentProviderPresentation,
|
||||||
PaymentProviderSpec,
|
PaymentProviderSpec,
|
||||||
@@ -22,6 +33,7 @@ PAYMENT_PROVIDER_SPECS: tuple[PaymentProviderSpec, ...] = (
|
|||||||
cryptopay.SPEC,
|
cryptopay.SPEC,
|
||||||
heleket.SPEC,
|
heleket.SPEC,
|
||||||
paykilla.SPEC,
|
paykilla.SPEC,
|
||||||
|
lava.SPEC,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -232,6 +232,7 @@ LOCALE_GROUPS = [
|
|||||||
"admin_settings_field_cryptopay_",
|
"admin_settings_field_cryptopay_",
|
||||||
"admin_settings_field_wata_",
|
"admin_settings_field_wata_",
|
||||||
"admin_settings_field_heleket_",
|
"admin_settings_field_heleket_",
|
||||||
|
"admin_settings_field_lava_",
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class PaymentContextMixin:
|
|||||||
"platega": "Platega",
|
"platega": "Platega",
|
||||||
"severpay": "SeverPay",
|
"severpay": "SeverPay",
|
||||||
"wata": "Wata",
|
"wata": "Wata",
|
||||||
|
"lava": "LAVA",
|
||||||
"cryptopay": "Crypto Pay",
|
"cryptopay": "Crypto Pay",
|
||||||
"paykilla": "PayKilla",
|
"paykilla": "PayKilla",
|
||||||
"telegram_stars": "Telegram Stars",
|
"telegram_stars": "Telegram Stars",
|
||||||
|
|||||||
@@ -329,7 +329,7 @@ class Settings(BaseSettings):
|
|||||||
STARS_ADMIN_ONLY_ENABLED: bool = Field(default=False)
|
STARS_ADMIN_ONLY_ENABLED: bool = Field(default=False)
|
||||||
PAYMENT_METHODS_ORDER: Optional[str] = Field(
|
PAYMENT_METHODS_ORDER: Optional[str] = Field(
|
||||||
default=None,
|
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(
|
SUBSCRIPTION_PURCHASE_DESCRIPTION_ENABLED: bool = Field(
|
||||||
default=True,
|
default=True,
|
||||||
@@ -1056,6 +1056,7 @@ class Settings(BaseSettings):
|
|||||||
"cryptopay",
|
"cryptopay",
|
||||||
"heleket",
|
"heleket",
|
||||||
"paykilla",
|
"paykilla",
|
||||||
|
"lava",
|
||||||
]
|
]
|
||||||
# Make sure default_order itself includes every registered spec.
|
# Make sure default_order itself includes every registered spec.
|
||||||
for sid in spec_ids:
|
for sid in spec_ids:
|
||||||
|
|||||||
@@ -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_ENABLED` | Показывать описание подписки перед выбором срока. |
|
||||||
| `SUBSCRIPTION_PURCHASE_DESCRIPTION_RU` / `SUBSCRIPTION_PURCHASE_DESCRIPTION_EN` | Локализованное описание подписки. |
|
| `SUBSCRIPTION_PURCHASE_DESCRIPTION_RU` / `SUBSCRIPTION_PURCHASE_DESCRIPTION_EN` | Локализованное описание подписки. |
|
||||||
| `PAYMENT_REQUEST_TIMEOUT_SECONDS` | Общий таймаут одного API-запроса к платёжному провайдеру, в секундах. По умолчанию `20`. |
|
| `PAYMENT_REQUEST_TIMEOUT_SECONDS` | Общий таймаут одного API-запроса к платёжному провайдеру, в секундах. По умолчанию `20`. |
|
||||||
@@ -237,6 +237,7 @@ proxy/Docker gateway и может отклонить валидный webhook.
|
|||||||
| `CRYPTOPAY_ENABLED` | Включает CryptoPay. |
|
| `CRYPTOPAY_ENABLED` | Включает CryptoPay. |
|
||||||
| `HELEKET_ENABLED` | Включает Heleket. |
|
| `HELEKET_ENABLED` | Включает Heleket. |
|
||||||
| `PAYKILLA_ENABLED` | Включает PayKilla. |
|
| `PAYKILLA_ENABLED` | Включает PayKilla. |
|
||||||
|
| `LAVA_ENABLED` | Включает LAVA. |
|
||||||
|
|
||||||
Конкретные ключи отображения:
|
Конкретные ключи отображения:
|
||||||
|
|
||||||
@@ -301,6 +302,12 @@ PAYMENT_PAYKILLA_WEBAPP_ICON
|
|||||||
PAYMENT_PAYKILLA_TELEGRAM_LABEL_RU
|
PAYMENT_PAYKILLA_TELEGRAM_LABEL_RU
|
||||||
PAYMENT_PAYKILLA_TELEGRAM_LABEL_EN
|
PAYMENT_PAYKILLA_TELEGRAM_LABEL_EN
|
||||||
PAYMENT_PAYKILLA_TELEGRAM_EMOJI
|
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
|
### YooKassa
|
||||||
@@ -416,6 +423,20 @@ Webhook настраивается в PayKilla Dashboard: **Settings -> Webhooks
|
|||||||
| `PAYKILLA_WEBHOOK_URL` | Точный публичный webhook URL для проверки подписи, если он отличается от `WEBHOOK_BASE_URL` + `/webhook/paykilla`. |
|
| `PAYKILLA_WEBHOOK_URL` | Точный публичный webhook URL для проверки подписи, если он отличается от `WEBHOOK_BASE_URL` + `/webhook/paykilla`. |
|
||||||
| `PAYKILLA_TRUSTED_IPS` | Необязательный список доверенных IP webhook-источников. |
|
| `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-цены
|
## Тарифы и legacy-цены
|
||||||
|
|
||||||
Рекомендуемый способ настройки тарифов - раздел **Система -> Тарифы** в админке. Он сохраняет JSON в `TARIFFS_CONFIG_PATH`.
|
Рекомендуемый способ настройки тарифов - раздел **Система -> Тарифы** в админке. Он сохраняет JSON в `TARIFFS_CONFIG_PATH`.
|
||||||
|
|||||||
@@ -52,7 +52,7 @@
|
|||||||
- внешний вид и доступность Web App: название, цвет, логотип и `WEBAPP_ENABLED`;
|
- внешний вид и доступность Web App: название, цвет, логотип и `WEBAPP_ENABLED`;
|
||||||
- инструкции подключения: `SUBSCRIPTION_GUIDES_ENABLED`, `SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED`, чтение конфига из Remnawave Panel, JSON-переопределение и резервный путь к файлу;
|
- инструкции подключения: `SUBSCRIPTION_GUIDES_ENABLED`, `SUBSCRIPTION_GUIDES_BOT_MENU_ENABLED`, чтение конфига из Remnawave Panel, JSON-переопределение и резервный путь к файлу;
|
||||||
- legacy-тарифы без JSON-каталога: периоды подписки, RUB/Stars цены, реферальные бонусы и пакеты трафика;
|
- legacy-тарифы без JSON-каталога: периоды подписки, RUB/Stars цены, реферальные бонусы и пакеты трафика;
|
||||||
- платежные провайдеры: включение методов, порядок кнопок, публичные параметры и секреты YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay, Heleket и Stars, а также текст и иконки кнопок оплаты;
|
- платежные провайдеры: включение методов, порядок кнопок, публичные параметры и секреты YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay, Heleket, LAVA и Stars, а также текст и иконки кнопок оплаты;
|
||||||
- пробный период, приветственный реферальный бонус, уведомления, логирование, Telegram антифлуд, поддержка, раздел устройств, лимит устройств и legacy-лимиты трафика.
|
- пробный период, приветственный реферальный бонус, уведомления, логирование, Telegram антифлуд, поддержка, раздел устройств, лимит устройств и legacy-лимиты трафика.
|
||||||
|
|
||||||
Секретные поля помечены как secret и не должны использоваться для произвольного просмотра старых значений. Настройки, которых нет в manifest, остаются только в `.env` или коде.
|
Секретные поля помечены как secret и не должны использоваться для произвольного просмотра старых значений. Настройки, которых нет в manifest, остаются только в `.env` или коде.
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ reverse proxy должен прокидывать `X-Forwarded-For`, а его I
|
|||||||
| CryptoPay | `WEBHOOK_BASE_URL` + `/webhook/cryptopay` | Указывается в настройках Crypto Bot / CryptoPay webhook. |
|
| CryptoPay | `WEBHOOK_BASE_URL` + `/webhook/cryptopay` | Указывается в настройках Crypto Bot / CryptoPay webhook. |
|
||||||
| Heleket | `WEBHOOK_BASE_URL` + `/webhook/heleket` | При необходимости включите `HELEKET_VERIFY_WEBHOOK_SIGNATURE` и `HELEKET_TRUSTED_IPS`. |
|
| Heleket | `WEBHOOK_BASE_URL` + `/webhook/heleket` | При необходимости включите `HELEKET_VERIFY_WEBHOOK_SIGNATURE` и `HELEKET_TRUSTED_IPS`. |
|
||||||
| PayKilla | `WEBHOOK_BASE_URL` + `/webhook/paykilla` | Указывается в PayKilla Dashboard -> Settings -> Webhooks; включите события оплаты инвойсов. |
|
| 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`. |
|
| Telegram Stars | Отдельный платежный webhook не нужен | Stars-события приходят через webhook Telegram-бота: `WEBHOOK_BASE_URL` + `/tg/webhook`. |
|
||||||
|
|
||||||
После настройки сделайте тестовый платеж и проверьте, что в логах `backend` видно входящий `POST` на нужный путь. Если провайдер сообщает, что адрес недоступен, сначала проверьте DNS/HTTPS и reverse proxy для `WEBHOOK_BASE_URL`, затем убедитесь, что путь начинается ровно с `/webhook/...` без `/api`, `/auth` и frontend-домена.
|
После настройки сделайте тестовый платеж и проверьте, что в логах `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)
|
- [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
|
||||||
|
|
||||||
Telegram Stars используются напрямую и поддерживаются в legacy-ценах и JSON-каталоге тарифов.
|
Telegram Stars используются напрямую и поддерживаются в legacy-ценах и JSON-каталоге тарифов.
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ Legacy-поля остаются алиасами: `prices_rub`, `conversion_rat
|
|||||||
| Heleket | настраиваемый список `HELEKET_SUPPORTED_CURRENCIES` |
|
| Heleket | настраиваемый список `HELEKET_SUPPORTED_CURRENCIES` |
|
||||||
| Platega | настраиваемый список `PLATEGA_SUPPORTED_CURRENCIES` |
|
| Platega | настраиваемый список `PLATEGA_SUPPORTED_CURRENCIES` |
|
||||||
| SeverPay | настраиваемый список `SEVERPAY_SUPPORTED_CURRENCIES` |
|
| SeverPay | настраиваемый список `SEVERPAY_SUPPORTED_CURRENCIES` |
|
||||||
|
| LAVA | `RUB` |
|
||||||
| Telegram Stars | `XTR`, отдельные Stars-цены |
|
| Telegram Stars | `XTR`, отдельные Stars-цены |
|
||||||
|
|
||||||
В админке раздел **Система → Тарифы** показывает текущую платежную валюту и матрицу провайдеров: включен ли метод, настроен ли сервис и будет ли он доступен при выбранной валюте. Для Platega, SeverPay и Heleket список валют нужно держать в соответствии с условиями вашего мерчанта.
|
В админке раздел **Система → Тарифы** показывает текущую платежную валюту и матрицу провайдеров: включен ли метод, настроен ли сервис и будет ли он доступен при выбранной валюте. Для Platega, SeverPay и Heleket список валют нужно держать в соответствии с условиями вашего мерчанта.
|
||||||
|
|||||||
Generated
+23
-1413
File diff suppressed because it is too large
Load Diff
@@ -42,7 +42,6 @@
|
|||||||
"vite": "^8.0.12"
|
"vite": "^8.0.12"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4"
|
||||||
"carta-md": "^4.11.2"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { Textarea } from "$components/ui/index.js";
|
||||||
|
import { Send } from "$components/ui/icons.js";
|
||||||
import { getContext, onMount } from "svelte";
|
import { getContext, onMount } from "svelte";
|
||||||
import { Send, Upload, Link as LinkIcon } from "$components/ui/icons.js";
|
|
||||||
import { AdminButton, AdminSelect } from "$components/patterns/admin/index.js";
|
|
||||||
import { Label } from "$components/ui/primitives.js";
|
import { Label } from "$components/ui/primitives.js";
|
||||||
import { Carta, MarkdownEditor } from "carta-md";
|
import { AdminButton, AdminSelect } from "$components/patterns/admin/index.js";
|
||||||
import "carta-md/default.css"; // Базовые стили
|
|
||||||
|
|
||||||
export let at;
|
export let at;
|
||||||
const broadcastStore = getContext("broadcastStore");
|
const broadcastStore = getContext("broadcastStore");
|
||||||
@@ -12,8 +11,6 @@
|
|||||||
$: ({
|
$: ({
|
||||||
broadcastTarget,
|
broadcastTarget,
|
||||||
broadcastText,
|
broadcastText,
|
||||||
broadcastMediaType,
|
|
||||||
broadcastMediaFileId,
|
|
||||||
broadcastBusy,
|
broadcastBusy,
|
||||||
broadcastResult,
|
broadcastResult,
|
||||||
broadcastCounts,
|
broadcastCounts,
|
||||||
@@ -22,29 +19,7 @@
|
|||||||
|
|
||||||
const BROADCAST_TARGET_OPTIONS = broadcastStore.BROADCAST_TARGET_OPTIONS;
|
const BROADCAST_TARGET_OPTIONS = broadcastStore.BROADCAST_TARGET_OPTIONS;
|
||||||
|
|
||||||
// Настройка Carta
|
// Append the resolved audience size to each option once counts are loaded.
|
||||||
const carta = new Carta({
|
|
||||||
sanitizer: (html) => html, // Можно подключить DOMPurify для безопасности
|
|
||||||
});
|
|
||||||
|
|
||||||
// Опции типов медиа
|
|
||||||
const MEDIA_TYPE_OPTIONS = [
|
|
||||||
{ value: "", label: at("media_type_none", {}, "Без медиа") },
|
|
||||||
{ value: "photo", label: "📷 Фото" },
|
|
||||||
{ value: "video", label: "🎬 Видео" },
|
|
||||||
{ value: "document", label: "📄 Документ" },
|
|
||||||
{ value: "audio", label: "🎵 Аудио" },
|
|
||||||
{ value: "animation", label: "🎞️ Анимация (GIF)" },
|
|
||||||
{ value: "voice", label: "🎤 Голосовое" },
|
|
||||||
{ value: "sticker", label: "😊 Стикер" },
|
|
||||||
{ value: "video_note", label: "📹 Видео-заметка" },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Локальное состояние для вкладок медиа
|
|
||||||
let mediaInputMode = "url"; // 'url' | 'file'
|
|
||||||
let selectedFile = null;
|
|
||||||
let isUploading = false;
|
|
||||||
|
|
||||||
$: targetOptions = BROADCAST_TARGET_OPTIONS.map((option) => {
|
$: targetOptions = BROADCAST_TARGET_OPTIONS.map((option) => {
|
||||||
const count = broadcastCounts?.[option.value];
|
const count = broadcastCounts?.[option.value];
|
||||||
if (count != null) return { ...option, label: `${option.label} (${count})` };
|
if (count != null) return { ...option, label: `${option.label} (${count})` };
|
||||||
@@ -52,46 +27,6 @@
|
|||||||
return option;
|
return option;
|
||||||
});
|
});
|
||||||
|
|
||||||
$: showFileIdInput = broadcastMediaType && broadcastMediaType !== "";
|
|
||||||
$: canSend = !broadcastBusy && (
|
|
||||||
(broadcastText && broadcastText.trim()) ||
|
|
||||||
(broadcastMediaType && broadcastMediaFileId && broadcastMediaFileId.trim())
|
|
||||||
);
|
|
||||||
|
|
||||||
// Обработчик загрузки файла
|
|
||||||
async function handleFileUpload(event) {
|
|
||||||
const file = event.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
selectedFile = file;
|
|
||||||
isUploading = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// TODO: заменить на реальный эндпоинт загрузки
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("file", file);
|
|
||||||
|
|
||||||
const response = await fetch("/api/admin/upload-media", {
|
|
||||||
method: "POST",
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error("Upload failed");
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
broadcastStore.updateField({
|
|
||||||
broadcastMediaFileId: data.file_id || data.url,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Upload error:", error);
|
|
||||||
// Можно показать уведомление об ошибке
|
|
||||||
} finally {
|
|
||||||
isUploading = false;
|
|
||||||
// Сбрасываем input, чтобы можно было загрузить тот же файл снова
|
|
||||||
event.target.value = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
broadcastStore.loadCounts();
|
broadcastStore.loadCounts();
|
||||||
});
|
});
|
||||||
@@ -102,10 +37,8 @@
|
|||||||
<h3>{at("broadcast_title", {}, "Рассылка")}</h3>
|
<h3>{at("broadcast_title", {}, "Рассылка")}</h3>
|
||||||
<small>{at("broadcast_subtitle", {}, "Доставка через очередь сообщений")}</small>
|
<small>{at("broadcast_subtitle", {}, "Доставка через очередь сообщений")}</small>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="admin-card-body">
|
<div class="admin-card-body">
|
||||||
<div class="admin-form">
|
<div class="admin-form">
|
||||||
<!-- Аудитория -->
|
|
||||||
<Label.Root class="admin-field-label">
|
<Label.Root class="admin-field-label">
|
||||||
<span>{at("broadcast_label_audience", {}, "Аудитория")}</span>
|
<span>{at("broadcast_label_audience", {}, "Аудитория")}</span>
|
||||||
<AdminSelect
|
<AdminSelect
|
||||||
@@ -115,336 +48,37 @@
|
|||||||
onValueChange={(value) => broadcastStore.updateField({ broadcastTarget: value })}
|
onValueChange={(value) => broadcastStore.updateField({ broadcastTarget: value })}
|
||||||
/>
|
/>
|
||||||
</Label.Root>
|
</Label.Root>
|
||||||
|
|
||||||
<!-- Тип медиа -->
|
|
||||||
<Label.Root class="admin-field-label">
|
<Label.Root class="admin-field-label">
|
||||||
<span>{at("broadcast_label_media_type", {}, "Тип медиа")}</span>
|
<span>{at("broadcast_label_text", {}, "Текст сообщения")}</span>
|
||||||
<AdminSelect
|
<small>{at("broadcast_hint_text", {}, "Поддерживается HTML-разметка Telegram")}</small>
|
||||||
value={broadcastMediaType}
|
<Textarea
|
||||||
items={MEDIA_TYPE_OPTIONS}
|
class="admin-textarea"
|
||||||
ariaLabel={at("broadcast_label_media_type", {}, "Тип медиа")}
|
rows="6"
|
||||||
onValueChange={(value) => {
|
|
||||||
broadcastStore.updateField({
|
|
||||||
broadcastMediaType: value,
|
|
||||||
// Очищаем file_id при смене типа
|
|
||||||
broadcastMediaFileId: "",
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Label.Root>
|
|
||||||
|
|
||||||
<!-- Блок загрузки медиа (показывается если выбран тип) -->
|
|
||||||
{#if showFileIdInput}
|
|
||||||
<div class="media-upload-block">
|
|
||||||
<!-- Переключатель режимов -->
|
|
||||||
<div class="media-tabs">
|
|
||||||
<button
|
|
||||||
class="media-tab {mediaInputMode === 'url' ? 'active' : ''}"
|
|
||||||
on:click={() => mediaInputMode = 'url'}
|
|
||||||
>
|
|
||||||
<LinkIcon size={14} />
|
|
||||||
{at("media_tab_url", {}, "По ссылке")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="media-tab {mediaInputMode === 'file' ? 'active' : ''}"
|
|
||||||
on:click={() => mediaInputMode = 'file'}
|
|
||||||
>
|
|
||||||
<Upload size={14} />
|
|
||||||
{at("media_tab_file", {}, "Загрузить файл")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Режим: ссылка -->
|
|
||||||
{#if mediaInputMode === 'url'}
|
|
||||||
<Label.Root class="admin-field-label">
|
|
||||||
<span>{at("broadcast_label_file_id", {}, "File ID или URL")}</span>
|
|
||||||
<small>
|
|
||||||
{at("broadcast_hint_file_id", {},
|
|
||||||
"Введите file_id из Telegram или прямую ссылку на файл")}
|
|
||||||
</small>
|
|
||||||
<input
|
|
||||||
class="admin-input"
|
|
||||||
type="text"
|
|
||||||
placeholder="https://example.com/image.jpg или file_id"
|
|
||||||
value={broadcastMediaFileId}
|
|
||||||
on:input={(e) => broadcastStore.updateField({
|
|
||||||
broadcastMediaFileId: e.target.value
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</Label.Root>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Режим: загрузка файла -->
|
|
||||||
{#if mediaInputMode === 'file'}
|
|
||||||
<div class="file-upload-area">
|
|
||||||
<label class="file-upload-label">
|
|
||||||
<Upload size={20} />
|
|
||||||
<span>
|
|
||||||
{selectedFile
|
|
||||||
? selectedFile.name
|
|
||||||
: at("media_upload_hint", {}, "Выберите файл или перетащите сюда")}
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/*,video/*,audio/*,.pdf,.doc,.docx"
|
|
||||||
on:change={handleFileUpload}
|
|
||||||
disabled={isUploading}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{#if isUploading}
|
|
||||||
<div class="upload-progress">
|
|
||||||
{at("media_uploading", {}, "Загрузка...")}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if broadcastMediaFileId && selectedFile}
|
|
||||||
<div class="upload-success">
|
|
||||||
✅ {at("media_uploaded", {}, "Файл загружен")}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Markdown-редактор -->
|
|
||||||
<Label.Root class="admin-field-label">
|
|
||||||
<span>
|
|
||||||
{at("broadcast_label_text", {}, "Текст сообщения")}
|
|
||||||
<span class="badge-markdown">Markdown</span>
|
|
||||||
</span>
|
|
||||||
<small>
|
|
||||||
{#if broadcastMediaType}
|
|
||||||
{at("broadcast_hint_caption", {}, "Этот текст будет подписью (caption) к медиа")}
|
|
||||||
{:else}
|
|
||||||
{at("broadcast_hint_markdown", {},
|
|
||||||
"Поддерживается Markdown и HTML-разметка Telegram")}
|
|
||||||
{/if}
|
|
||||||
</small>
|
|
||||||
|
|
||||||
<!-- Контейнер редактора с превью -->
|
|
||||||
<div class="markdown-editor-wrapper">
|
|
||||||
<MarkdownEditor
|
|
||||||
{carta}
|
|
||||||
value={broadcastText}
|
value={broadcastText}
|
||||||
on:change={(e) => broadcastStore.updateField({
|
on:input={(e) => broadcastStore.updateField({ broadcastText: e.target.value })}
|
||||||
broadcastText: e.detail.value
|
|
||||||
})}
|
|
||||||
placeholder={at("broadcast_placeholder", {},
|
|
||||||
"Введите текст сообщения...")}
|
|
||||||
/>
|
/>
|
||||||
<!-- Превью отображается автоматически под редактором -->
|
|
||||||
</div>
|
|
||||||
</Label.Root>
|
</Label.Root>
|
||||||
|
<div style="display:flex; gap:8px; align-items:center;">
|
||||||
<!-- Кнопка отправки -->
|
|
||||||
<div class="actions-row">
|
|
||||||
<AdminButton
|
<AdminButton
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onclick={broadcastStore.runBroadcast}
|
onclick={broadcastStore.runBroadcast}
|
||||||
disabled={!canSend}
|
disabled={broadcastBusy || !broadcastText.trim()}
|
||||||
>
|
>
|
||||||
<Send size={14} />
|
<Send size={14} />
|
||||||
{broadcastBusy
|
{broadcastBusy
|
||||||
? at("btn_sending", {}, "Отправка...")
|
? at("btn_sending", {}, "Отправка...")
|
||||||
: at("btn_queue", {}, "Поставить в очередь")}
|
: at("btn_queue", {}, "Поставить в очередь")}
|
||||||
</AdminButton>
|
</AdminButton>
|
||||||
|
|
||||||
{#if broadcastResult}
|
{#if broadcastResult}
|
||||||
<span class="admin-muted">
|
<span class="admin-muted"
|
||||||
{at("broadcast_stat_queued", {}, "В очереди")}: {broadcastResult.queued} ·
|
>{at("broadcast_stat_queued", {}, "В очереди")}: {broadcastResult.queued} · {at(
|
||||||
{at("broadcast_stat_failed", {}, "Неудач")}: {broadcastResult.failed}
|
"broadcast_stat_failed",
|
||||||
</span>
|
{},
|
||||||
|
"Неудач"
|
||||||
|
)}: {broadcastResult.failed}</span
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
|
||||||
/* --- Стили для медиа-блока --- */
|
|
||||||
.media-upload-block {
|
|
||||||
margin: 12px 0;
|
|
||||||
padding: 12px 16px;
|
|
||||||
background: var(--bg-subtle, #f8f9fa);
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid var(--border, #e9ecef);
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
background: var(--bg-surface, #f1f3f5);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-tab {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 6px 14px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-secondary, #6c757d);
|
|
||||||
font-size: 13px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-tab:hover {
|
|
||||||
background: var(--bg-hover, #e9ecef);
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-tab.active {
|
|
||||||
background: var(--bg-white, #ffffff);
|
|
||||||
color: var(--text-primary, #212529);
|
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Стили для загрузки файлов --- */
|
|
||||||
.file-upload-area {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-upload-label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 20px;
|
|
||||||
border: 2px dashed var(--border, #dee2e6);
|
|
||||||
border-radius: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
color: var(--text-secondary, #6c757d);
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-upload-label:hover {
|
|
||||||
border-color: var(--primary, #0d6efd);
|
|
||||||
background: var(--bg-subtle, #f8f9fa);
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-upload-label input {
|
|
||||||
position: absolute;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
padding: 0;
|
|
||||||
margin: -1px;
|
|
||||||
overflow: hidden;
|
|
||||||
clip: rect(0, 0, 0, 0);
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-progress {
|
|
||||||
color: var(--text-secondary, #6c757d);
|
|
||||||
font-size: 13px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-success {
|
|
||||||
color: var(--success, #198754);
|
|
||||||
font-size: 13px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Стили для Markdown-редактора --- */
|
|
||||||
.markdown-editor-wrapper {
|
|
||||||
margin-top: 6px;
|
|
||||||
border: 1px solid var(--border, #dee2e6);
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdown-editor-wrapper :global(.carta-editor) {
|
|
||||||
min-height: 180px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.6;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
resize: vertical;
|
|
||||||
width: 100%;
|
|
||||||
background: var(--bg-white, #ffffff);
|
|
||||||
color: var(--text-primary, #212529);
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdown-editor-wrapper :global(.carta-editor:focus) {
|
|
||||||
box-shadow: inset 0 0 0 2px var(--primary, #0d6efd);
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdown-editor-wrapper :global(.carta-preview) {
|
|
||||||
padding: 16px;
|
|
||||||
border-top: 1px solid var(--border, #dee2e6);
|
|
||||||
background: var(--bg-subtle, #f8f9fa);
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.7;
|
|
||||||
max-height: 300px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Исправлено: :global(...) перенесён в конец селектора */
|
|
||||||
.markdown-editor-wrapper :global(.carta-preview h1),
|
|
||||||
.markdown-editor-wrapper :global(.carta-preview h2),
|
|
||||||
.markdown-editor-wrapper :global(.carta-preview h3) {
|
|
||||||
margin: 12px 0 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdown-editor-wrapper :global(.carta-preview code) {
|
|
||||||
background: var(--bg-code, #e9ecef);
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdown-editor-wrapper :global(.carta-preview pre) {
|
|
||||||
background: var(--bg-code-block, #1e1e1e);
|
|
||||||
color: #d4d4d4;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-radius: 6px;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Общие стили --- */
|
|
||||||
.badge-markdown {
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: var(--primary-subtle, #e7f1ff);
|
|
||||||
color: var(--primary, #0d6efd);
|
|
||||||
margin-left: 6px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
align-items: center;
|
|
||||||
margin-top: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border: 1px solid var(--border, #dee2e6);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 14px;
|
|
||||||
transition: border-color 0.2s;
|
|
||||||
background: var(--bg-white, #ffffff);
|
|
||||||
color: var(--text-primary, #212529);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-input:focus {
|
|
||||||
border-color: var(--primary, #0d6efd);
|
|
||||||
outline: none;
|
|
||||||
box-shadow: 0 0 0 3px rgba(13, 110, 253, 0.1);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
// frontend/src/lib/admin/stores/broadcastStore.js
|
|
||||||
|
|
||||||
import { writable } from "svelte/store";
|
import { writable } from "svelte/store";
|
||||||
|
|
||||||
export function createBroadcastStore({ api, onToast, at }) {
|
export function createBroadcastStore({ api, onToast, at }) {
|
||||||
@@ -12,10 +10,6 @@ export function createBroadcastStore({ api, onToast, at }) {
|
|||||||
const state = writable({
|
const state = writable({
|
||||||
broadcastTarget: "all",
|
broadcastTarget: "all",
|
||||||
broadcastText: "",
|
broadcastText: "",
|
||||||
// --- НОВЫЕ ПОЛЯ ДЛЯ МЕДИА ---
|
|
||||||
broadcastMediaType: "", // например "photo", "video" и т.д.
|
|
||||||
broadcastMediaFileId: "", // file_id или URL
|
|
||||||
// ---------------------------
|
|
||||||
broadcastBusy: false,
|
broadcastBusy: false,
|
||||||
broadcastResult: null,
|
broadcastResult: null,
|
||||||
broadcastCounts: cachedCounts?.counts || null,
|
broadcastCounts: cachedCounts?.counts || null,
|
||||||
@@ -107,46 +101,25 @@ export function createBroadcastStore({ api, onToast, at }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function runBroadcast() {
|
async function runBroadcast() {
|
||||||
// Собираем текущие значения из стора
|
|
||||||
let text = "";
|
let text = "";
|
||||||
let target = "";
|
let target = "";
|
||||||
let mediaType = "";
|
|
||||||
let mediaFileId = "";
|
|
||||||
|
|
||||||
state.update((s) => {
|
state.update((s) => {
|
||||||
text = s.broadcastText;
|
text = s.broadcastText;
|
||||||
target = s.broadcastTarget;
|
target = s.broadcastTarget;
|
||||||
mediaType = s.broadcastMediaType || "";
|
|
||||||
mediaFileId = s.broadcastMediaFileId || "";
|
|
||||||
s.broadcastBusy = true;
|
s.broadcastBusy = true;
|
||||||
s.broadcastResult = null;
|
s.broadcastResult = null;
|
||||||
return s;
|
return s;
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Формируем payload
|
|
||||||
const payload = {
|
|
||||||
target,
|
|
||||||
text: text.trim(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Добавляем медиа, только если есть и тип, и file_id
|
|
||||||
if (mediaType && mediaFileId && mediaFileId.trim()) {
|
|
||||||
payload.media_type = mediaType;
|
|
||||||
payload.media_file_id = mediaFileId.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await api("/admin/broadcast", {
|
const res = await api("/admin/broadcast", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify({ target, text }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res?.ok) {
|
if (res?.ok) {
|
||||||
state.update((s) => ({
|
state.update((s) => ({
|
||||||
...s,
|
...s,
|
||||||
broadcastText: "",
|
broadcastText: "",
|
||||||
broadcastMediaType: "",
|
|
||||||
broadcastMediaFileId: "",
|
|
||||||
broadcastResult: { queued: res.queued || 0, failed: res.failed || 0 },
|
broadcastResult: { queued: res.queued || 0, failed: res.failed || 0 },
|
||||||
}));
|
}));
|
||||||
onToast(at("broadcast_started", {}, "Рассылка запущена"));
|
onToast(at("broadcast_started", {}, "Рассылка запущена"));
|
||||||
@@ -162,15 +135,6 @@ export function createBroadcastStore({ api, onToast, at }) {
|
|||||||
state.update((s) => ({ ...s, ...fields }));
|
state.update((s) => ({ ...s, ...fields }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Дополнительный метод для ручного сброса медиа (например, после загрузки файла)
|
|
||||||
function resetMedia() {
|
|
||||||
state.update((s) => ({
|
|
||||||
...s,
|
|
||||||
broadcastMediaType: "",
|
|
||||||
broadcastMediaFileId: "",
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subscribe: state.subscribe,
|
subscribe: state.subscribe,
|
||||||
set: state.set,
|
set: state.set,
|
||||||
@@ -178,7 +142,6 @@ export function createBroadcastStore({ api, onToast, at }) {
|
|||||||
runBroadcast,
|
runBroadcast,
|
||||||
updateField,
|
updateField,
|
||||||
loadCounts,
|
loadCounts,
|
||||||
resetMedia,
|
|
||||||
BROADCAST_TARGET_OPTIONS,
|
BROADCAST_TARGET_OPTIONS,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -37,7 +37,6 @@ export {
|
|||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
LifeBuoy,
|
LifeBuoy,
|
||||||
Lock,
|
Lock,
|
||||||
Link,
|
|
||||||
LockKeyhole,
|
LockKeyhole,
|
||||||
Mail,
|
Mail,
|
||||||
Map,
|
Map,
|
||||||
|
|||||||
@@ -484,6 +484,11 @@ export const DEMO_DATASET = {
|
|||||||
name: "PayKilla",
|
name: "PayKilla",
|
||||||
icon: "Bitcoin",
|
icon: "Bitcoin",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "lava",
|
||||||
|
name: "LAVA",
|
||||||
|
icon: "CreditCard",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
referral: {
|
referral: {
|
||||||
code: "DEMO0001",
|
code: "DEMO0001",
|
||||||
|
|||||||
@@ -670,7 +670,7 @@
|
|||||||
"section_order": 4,
|
"section_order": 4,
|
||||||
"subsection": "common",
|
"subsection": "common",
|
||||||
"label": "Порядок методов оплаты",
|
"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_label_key": "admin_settings_field_payment_methods_order_label",
|
||||||
"i18n_description_key": "admin_settings_field_payment_methods_order_description",
|
"i18n_description_key": "admin_settings_field_payment_methods_order_description",
|
||||||
"i18n_subsection_key": "admin_settings_subsection_common",
|
"i18n_subsection_key": "admin_settings_subsection_common",
|
||||||
@@ -4598,6 +4598,393 @@
|
|||||||
"overridden": false,
|
"overridden": false,
|
||||||
"updated_at": null,
|
"updated_at": null,
|
||||||
"webhook_base_url_configured": false
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1220,6 +1220,7 @@
|
|||||||
"admin_settings_subsection_wata": "Wata",
|
"admin_settings_subsection_wata": "Wata",
|
||||||
"admin_settings_subsection_heleket": "Heleket",
|
"admin_settings_subsection_heleket": "Heleket",
|
||||||
"admin_settings_subsection_paykilla": "PayKilla",
|
"admin_settings_subsection_paykilla": "PayKilla",
|
||||||
|
"admin_settings_subsection_lava": "LAVA",
|
||||||
"admin_settings_provider_webhook_url": "Webhook URL",
|
"admin_settings_provider_webhook_url": "Webhook URL",
|
||||||
"admin_settings_provider_webhook_url_hint": "Use this URL in the provider webhook settings.",
|
"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.",
|
"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_return_url_label": "SeverPay Return URL",
|
||||||
"admin_settings_field_severpay_lifetime_minutes_label": "SeverPay Lifetime Minutes",
|
"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_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_enabled_label": "CryptoPay Enabled",
|
||||||
"admin_settings_field_cryptopay_token_label": "CryptoPay Token",
|
"admin_settings_field_cryptopay_token_label": "CryptoPay Token",
|
||||||
"admin_settings_field_cryptopay_network_label": "CryptoPay Network",
|
"admin_settings_field_cryptopay_network_label": "CryptoPay Network",
|
||||||
|
|||||||
@@ -1220,6 +1220,7 @@
|
|||||||
"admin_settings_subsection_wata": "Wata",
|
"admin_settings_subsection_wata": "Wata",
|
||||||
"admin_settings_subsection_heleket": "Heleket",
|
"admin_settings_subsection_heleket": "Heleket",
|
||||||
"admin_settings_subsection_paykilla": "PayKilla",
|
"admin_settings_subsection_paykilla": "PayKilla",
|
||||||
|
"admin_settings_subsection_lava": "LAVA",
|
||||||
"admin_settings_provider_webhook_url": "Webhook URL",
|
"admin_settings_provider_webhook_url": "Webhook URL",
|
||||||
"admin_settings_provider_webhook_url_hint": "Укажите этот адрес в настройках вебхуков провайдера.",
|
"admin_settings_provider_webhook_url_hint": "Укажите этот адрес в настройках вебхуков провайдера.",
|
||||||
"admin_settings_panel_webhook_url_hint": "Укажите этот адрес как WEBHOOK_URL в Remnawave Panel.",
|
"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_return_url_label": "Return URL",
|
||||||
"admin_settings_field_severpay_lifetime_minutes_label": "Срок жизни ссылки (мин)",
|
"admin_settings_field_severpay_lifetime_minutes_label": "Срок жизни ссылки (мин)",
|
||||||
"admin_settings_field_severpay_lifetime_minutes_description": "30..4320; пусто — значение провайдера",
|
"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_enabled_label": "Включена",
|
||||||
"admin_settings_field_cryptopay_token_label": "Token",
|
"admin_settings_field_cryptopay_token_label": "Token",
|
||||||
"admin_settings_field_cryptopay_network_label": "Network",
|
"admin_settings_field_cryptopay_network_label": "Network",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ def _isolate_provider_env(monkeypatch):
|
|||||||
"SEVERPAY_",
|
"SEVERPAY_",
|
||||||
"WATA_",
|
"WATA_",
|
||||||
"HELEKET_",
|
"HELEKET_",
|
||||||
|
"LAVA_",
|
||||||
"CRYPTOPAY_",
|
"CRYPTOPAY_",
|
||||||
"YOOKASSA_",
|
"YOOKASSA_",
|
||||||
"PAYMENT_FREEKASSA_",
|
"PAYMENT_FREEKASSA_",
|
||||||
@@ -36,6 +37,7 @@ def _isolate_provider_env(monkeypatch):
|
|||||||
"PAYMENT_SEVERPAY_",
|
"PAYMENT_SEVERPAY_",
|
||||||
"PAYMENT_WATA_",
|
"PAYMENT_WATA_",
|
||||||
"PAYMENT_HELEKET_",
|
"PAYMENT_HELEKET_",
|
||||||
|
"PAYMENT_LAVA_",
|
||||||
"PAYMENT_CRYPTOPAY_",
|
"PAYMENT_CRYPTOPAY_",
|
||||||
"PAYMENT_YOOKASSA_",
|
"PAYMENT_YOOKASSA_",
|
||||||
"PAYMENT_STARS_",
|
"PAYMENT_STARS_",
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ _PROVIDER_ENV_PREFIXES = (
|
|||||||
"SEVERPAY_",
|
"SEVERPAY_",
|
||||||
"WATA_",
|
"WATA_",
|
||||||
"HELEKET_",
|
"HELEKET_",
|
||||||
|
"LAVA_",
|
||||||
"CRYPTOPAY_",
|
"CRYPTOPAY_",
|
||||||
"YOOKASSA_",
|
"YOOKASSA_",
|
||||||
"STARS_",
|
"STARS_",
|
||||||
@@ -176,6 +177,7 @@ class BuildServicesWiringTests(unittest.TestCase):
|
|||||||
"wata_service",
|
"wata_service",
|
||||||
"heleket_service",
|
"heleket_service",
|
||||||
"paykilla_service",
|
"paykilla_service",
|
||||||
|
"lava_service",
|
||||||
}
|
}
|
||||||
self.assertEqual(set(services), expected_keys)
|
self.assertEqual(set(services), expected_keys)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -33,7 +35,9 @@ def test_shell_installer_downloads_raw_files_and_runs_import_in_container():
|
|||||||
script = INSTALL_SCRIPT.read_text(encoding="utf-8")
|
script = INSTALL_SCRIPT.read_text(encoding="utf-8")
|
||||||
|
|
||||||
assert script.startswith("#!/bin/sh")
|
assert script.startswith("#!/bin/sh")
|
||||||
assert "raw.githubusercontent.com" in script
|
raw_url_template = re.search(r"printf '(https://[^']+)' \"\$repo\"", script)
|
||||||
|
assert raw_url_template is not None
|
||||||
|
assert urlsplit(raw_url_template.group(1)).hostname == "raw.githubusercontent.com"
|
||||||
assert "git clone" not in script
|
assert "git clone" not in script
|
||||||
assert "backend python backend/scripts/import_legacy.py" in script
|
assert "backend python backend/scripts/import_legacy.py" in script
|
||||||
assert "Optional source Remnashop .env path" in script
|
assert "Optional source Remnashop .env path" in script
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -66,6 +66,7 @@ _PROVIDER_MODULES = {
|
|||||||
"wata": "WataService",
|
"wata": "WataService",
|
||||||
"heleket": "HeleketService",
|
"heleket": "HeleketService",
|
||||||
"paykilla": "PaykillaService",
|
"paykilla": "PaykillaService",
|
||||||
|
"lava": "LavaService",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -128,6 +129,7 @@ def test_service_keys_and_statuses_come_from_provider_specs():
|
|||||||
"cryptopay_service",
|
"cryptopay_service",
|
||||||
"heleket_service",
|
"heleket_service",
|
||||||
"paykilla_service",
|
"paykilla_service",
|
||||||
|
"lava_service",
|
||||||
}
|
}
|
||||||
assert set(pending_statuses()) >= {
|
assert set(pending_statuses()) >= {
|
||||||
"pending",
|
"pending",
|
||||||
@@ -140,6 +142,7 @@ def test_service_keys_and_statuses_come_from_provider_specs():
|
|||||||
"pending_stars",
|
"pending_stars",
|
||||||
"pending_heleket",
|
"pending_heleket",
|
||||||
"pending_paykilla",
|
"pending_paykilla",
|
||||||
|
"pending_lava",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ _PROVIDERS_FROM_CALLERS = {
|
|||||||
"platega",
|
"platega",
|
||||||
"severpay",
|
"severpay",
|
||||||
"wata",
|
"wata",
|
||||||
|
"lava",
|
||||||
"cryptopay",
|
"cryptopay",
|
||||||
"paykilla",
|
"paykilla",
|
||||||
"telegram_stars",
|
"telegram_stars",
|
||||||
|
|||||||
Reference in New Issue
Block a user