From 6311532c4527acc1a0937dd848654cb75861f6ed Mon Sep 17 00:00:00 2001 From: savsis Date: Sun, 13 Sep 2026 19:23:25 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20payments=20setup=20wizard=20=E2=80=94?= =?UTF-8?q?=20legal=20pages=20+=20YooKassa=20keys=20from=20the=20admin=20U?= =?UTF-8?q?I?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The site/offer.html and site/privacy.html legal templates existed in the repo but were never actually wired to anything — no route served them, install.sh never copied them anywhere. Nobody deploying this for real payments had a live offer/privacy page, which YooKassa requires for merchant approval. Rewrote both templates with {{TOKEN}} placeholders (new legal.py renders them from .env-backed settings, read fresh on every request, no restart needed to fix a typo) and added a proper setup section in the Payments tab: business type/name/INN/support contact/refund window, saved via POST /admin/api/payments/legal-settings, live at GET /offer and /privacy immediately. Unset fields render as a visible not-set-yet badge instead of breaking the page. Effective date auto-stamps once on first save and stays stable across later edits (verified: editing the name afterward doesn't reset it). YooKassa shop_id + secret_key get their own section: validated live against YooKassa's own /v3/me before being saved (same pattern as the existing bot-token getMe check), never echoed back to the frontend once set. Includes an inline guide — where to find the keys in YooKassa's dashboard, and that self-employed registration there needs just passport + INN, no separate cash register. Both new dropdowns use the existing custom .dd component, not a raw select element — this codebase deliberately doesn't use native selects (see the comment already in admin.html) because of the OS-rendered white popup, so a new form had to follow that pattern, not reintroduce it. Verified: template rendering with empty settings (fallback badges, no leftover tokens) and fully filled settings, HTML-escaping of field values (a script tag in a field renders as text, not markup), the one-time-only date stamp, and all new routes registering correctly. Also fixed a stale doc string in the panel's own admin-facing docs tab that still quoted the old rate-limit numbers from before the real limits shipped. Co-Authored-By: Claude Sonnet 5 --- admin.html | 121 ++++++++++++++++++++++++++++++++++++++++++++-- api.py | 62 ++++++++++++++++++++++++ legal.py | 52 ++++++++++++++++++++ payments.py | 5 ++ site/offer.html | 16 +++--- site/privacy.html | 12 ++--- 6 files changed, 249 insertions(+), 19 deletions(-) create mode 100644 legal.py diff --git a/admin.html b/admin.html index 76848e4..69cc53a 100644 --- a/admin.html +++ b/admin.html @@ -502,9 +502,53 @@
Платежи
ЮKassa / Platega — история и статус, с проверкой на стороне провайдера при пропущенном вебхуке
-
- -
ПользовательСерверТарифПровайдерСуммаСозданСтатус
+ +
+

Настройка приёма платежей

+

Заполни один раз — панель сама соберёт из этого публичную оферту и политику конфиденциальности (обязательны для подключения ЮKassa) на своих страницах, готовых к показу клиентам.

+ +
+

Как это работает

+

1. Заполни реквизиты ниже (кто ты для закона — самозанятый/ИП/ООО, ИНН, контакты). Это те же данные, что ЮKassa попросит при регистрации магазина.

+

2. Подключи ЮKassa: заведи магазин на yookassa.ru, в личном кабинете возьми shop_id и секретный ключ (Настройки → Ключи API), вставь сюда. Панель сразу проверит их и сохранит.

+

3. Ссылки на готовые оферту и политику (https://{домен}/offer, /privacy) — дай их ЮKassa при регистрации магазина, она их обязательно спросит.

+

Самозанятым для приёма платежей от физлиц регистрация магазина в ЮKassa доступна напрямую по паспорту и ИНН, без онлайн-кассы — она уже встроена в сервис ЮKassa. ИП/ООО — обычная регистрация магазина.

+
+ +

Реквизиты для документов

+
+
+
+
+
+
+
+
+
+
+
+ +
+ +

Страницы всегда доступны по ссылкам: /offer · /privacy — незаполненные поля показываются пометкой, что их надо указать, страница не ломается.

+ +

ЮKassa — ключи API

+
+
+
+
+
+
+

Панель сама постучится в ЮKassa (/v3/me) и сохранит ключи только если они рабочие. После сохранения включаются приём оплаты и вебхуки; бот перезапустится сам, для самой панели (обработка вебхуков) один раз выполни на сервере mbs restart.

+
+
+ +
+

История

+
+ +
ПользовательСерверТарифПровайдерСуммаСозданСтатус
+
@@ -526,7 +570,7 @@

Пароль и безопасность

Пароль админ-панели меняется командой mbs pass на сервере (без аргумента — сгенерит случайный). Панель физически откажется стартовать, если в .env стоит "change-me"/"admin"/что-то короче 8 символов — так что пропустить это не выйдет по-тихому.

-

Сессия логина живёт в httpOnly-куке, на /admin/api/login висит rate-limit (5 попыток в минуту с одного IP). SSH-доступ на сервер — сам по себе, панель на него не влияет; отдельно стоит подумать про отключение root-логина по паролю в пользу ключей, если этого ещё не сделано.

+

Сессия логина живёт в httpOnly-куке, опционально поверх пароля — 2FA (TOTP). На /admin/api/login и /admin/api/login/totp висит rate-limit (10 попыток за 15 минут на пароль, 10 за 5 минут на код — с одного IP). SSH-доступ на сервер — сам по себе, панель на него не влияет; отдельно стоит подумать про отключение root-логина по паролю в пользу ключей, если этого ещё не сделано.

@@ -685,7 +729,7 @@ function showView(name) { if (name === "gifts") loadGifts(); if (name === "nodes") loadNodes(); if (name === "traffic") loadTraffic(); - if (name === "payments") loadPayments(); + if (name === "payments") { loadPayments(); loadPaymentsSettings(); } if (name === "settings") { loadBotSettings(); loadAdmins(); loadTotpStatus(); } } @@ -860,6 +904,20 @@ function initDropdowns() { }); DD.ucGrantNode = createDropdown("uc-grant-node", { options: [], placeholder: "Сервер" }); DD.ucGrantPlan = createDropdown("uc-grant-plan", { options: [], placeholder: "Срок" }); + DD.legalType = createDropdown("legal-type", { + options: [ + { value: "self", label: "Самозанятый" }, + { value: "ip", label: "ИП" }, + { value: "ooo", label: "ООО" }, + ], + value: "self", placeholder: "Кто ты", + onChange: (type) => { + const label = document.getElementById("legal-name-label"); + const input = document.getElementById("legal-name"); + if (type === "ooo") { label.textContent = "Название"; input.placeholder = 'ООО «Ромашка»'; } + else { label.textContent = "ФИО"; input.placeholder = "Иванов Иван Иванович"; } + }, + }); } const ICONS = { @@ -941,6 +999,59 @@ async function checkPayment(id, btn) { } } +async function loadPaymentsSettings() { + const [legalRes, ykRes] = await Promise.all([ + api("/admin/api/payments/legal-settings"), + api("/admin/api/payments/yookassa-settings"), + ]); + document.getElementById("legal-name").value = legalRes.LEGAL_NAME || ""; + document.getElementById("legal-inn").value = legalRes.LEGAL_INN || ""; + document.getElementById("legal-refund").value = legalRes.REFUND_HOURS || "24"; + document.getElementById("legal-contact").value = legalRes.SUPPORT_CONTACT || ""; + document.getElementById("legal-email").value = legalRes.SUPPORT_EMAIL || ""; + + document.getElementById("yk-shop-id").value = ykRes.shop_id || ""; + const status = document.getElementById("yookassa-status"); + if (ykRes.enabled && ykRes.has_secret) { + status.innerHTML = 'подключена shop_id: ' + esc(ykRes.shop_id); + } else { + status.innerHTML = 'не настроена'; + } +} + +async function saveLegalSettings() { + const result = document.getElementById("legal-result"); + const body = { + LEGAL_NAME: document.getElementById("legal-name").value.trim(), + LEGAL_INN: document.getElementById("legal-inn").value.trim(), + REFUND_HOURS: document.getElementById("legal-refund").value.trim() || "24", + SUPPORT_CONTACT: document.getElementById("legal-contact").value.trim(), + SUPPORT_EMAIL: document.getElementById("legal-email").value.trim(), + }; + try { + await api("/admin/api/payments/legal-settings", { method: "POST", body: JSON.stringify(body) }); + result.innerHTML = '

Сохранено — страницы /offer и /privacy обновились сразу, без рестарта

'; + } catch (e) { + result.innerHTML = '

Не получилось: ' + esc(e.message) + '

'; + } +} + +async function saveYookassaSettings() { + const shop_id = document.getElementById("yk-shop-id").value.trim(); + const secret_key = document.getElementById("yk-secret-key").value.trim(); + const result = document.getElementById("yookassa-result"); + if (!shop_id || !secret_key) return; + result.innerHTML = '

Проверяю ключи у ЮKassa…

'; + try { + const res = await api("/admin/api/payments/yookassa-settings", { method: "POST", body: JSON.stringify({ shop_id, secret_key }) }); + result.innerHTML = `

Ключи рабочие, сохранено.${res.restarted_bot ? " Бот перезапущен." : " Бот сам не перезапустился — выполни mbs restart."} Для приёма вебхуков панелью выполни на сервере mbs restart.

`; + document.getElementById("yk-secret-key").value = ""; + loadPaymentsSettings(); + } catch (e) { + result.innerHTML = '

Не получилось: ' + esc(e.message) + '

'; + } +} + async function loadBotSettings() { const data = await api("/admin/api/settings/bot"); document.getElementById("settings-bot-username").textContent = "@" + data.username; diff --git a/api.py b/api.py index 147ab2a..a034366 100644 --- a/api.py +++ b/api.py @@ -12,6 +12,7 @@ from fastapi.responses import HTMLResponse, PlainTextResponse, FileResponse import backup import db +import legal import links import nodeprov import payments @@ -374,6 +375,57 @@ def admin_check_payment(payment_id: str, request: Request): return {"status": status} +@app.get("/admin/api/payments/legal-settings") +def admin_get_legal_settings(request: Request): + require_admin(request) + return legal.get_settings() + + +@app.post("/admin/api/payments/legal-settings") +def admin_set_legal_settings(request: Request, body: dict = Body(...)): + require_admin(request) + for key in ("LEGAL_NAME", "LEGAL_INN", "REFUND_HOURS", "SUPPORT_CONTACT", "SUPPORT_EMAIL"): + if key in body: + _update_env_var(key, str(body[key]).strip()) + if not legal.read_env_var("OFFER_EFFECTIVE_DATE"): + _update_env_var("OFFER_EFFECTIVE_DATE", datetime.date.today().strftime("%d.%m.%Y")) + return {"ok": True, "settings": legal.get_settings()} + + +@app.get("/admin/api/payments/yookassa-settings") +def admin_get_yookassa_settings(request: Request): + require_admin(request) + return { + "enabled": payments.YOOKASSA_ENABLED, + "shop_id": legal.read_env_var("YOOKASSA_SHOP_ID", ""), + "has_secret": bool(legal.read_env_var("YOOKASSA_SECRET_KEY", "")), + } + + +@app.post("/admin/api/payments/yookassa-settings") +def admin_set_yookassa_settings(request: Request, body: dict = Body(...)): + require_admin(request) + shop_id = (body.get("shop_id") or "").strip() + secret_key = (body.get("secret_key") or "").strip() + if not shop_id or not secret_key: + raise HTTPException(400, "shop_id и secret_key обязательны") + try: + payments.validate_yookassa_credentials(shop_id, secret_key) + except Exception: + raise HTTPException(401, "ЮKassa не приняла эти ключи — проверь shop_id и секретный ключ") + _update_env_var("YOOKASSA_SHOP_ID", shop_id) + _update_env_var("YOOKASSA_SECRET_KEY", secret_key) + _update_env_var("YOOKASSA_ENABLED", "true") + _update_env_var("PAYMENTS_ENABLED", "true") + restarted = False + try: + subprocess.run(["systemctl", "restart", "mbs-bot"], check=True, timeout=15) + restarted = True + except Exception: + restarted = False + return {"ok": True, "restarted_bot": restarted} + + @app.post("/payments/webhook/yookassa") async def yookassa_webhook(request: Request): body = await request.json() @@ -994,3 +1046,13 @@ def root(request: Request): @app.get("/admin") def admin_page(): return FileResponse(ADMIN_HTML_PATH, headers=_NO_CACHE) + + +@app.get("/offer", response_class=HTMLResponse) +def offer_page(): + return legal.render("offer.html") + + +@app.get("/privacy", response_class=HTMLResponse) +def privacy_page(): + return legal.render("privacy.html") diff --git a/legal.py b/legal.py new file mode 100644 index 0000000..36c3039 --- /dev/null +++ b/legal.py @@ -0,0 +1,52 @@ +import html +import os + +from config import BASE_DIR, BOT_USERNAME + +ENV_PATH = os.path.join(BASE_DIR, ".env") +SITE_DIR = os.path.join(BASE_DIR, "site") + +FIELD_KEYS = ["LEGAL_NAME", "LEGAL_INN", "REFUND_HOURS", "SUPPORT_CONTACT", "SUPPORT_EMAIL", "OFFER_EFFECTIVE_DATE"] + + +def read_env_var(key: str, default: str = "") -> str: + if not os.path.exists(ENV_PATH): + return default + with open(ENV_PATH, encoding="utf-8") as f: + for line in f: + line = line.strip() + if line.startswith(f"{key}="): + return line[len(key) + 1:] + return default + + +def get_settings() -> dict: + return {key: read_env_var(key) for key in FIELD_KEYS} + + +def _fallback(label: str) -> str: + return f'{html.escape(label)}' + + +def _field(value: str, fallback_label: str) -> str: + return html.escape(value) if value else _fallback(fallback_label) + + +def render(template_name: str) -> str: + path = os.path.join(SITE_DIR, template_name) + with open(path, encoding="utf-8") as f: + content = f.read() + + s = get_settings() + replacements = { + "EFFECTIVE_DATE": _field(s["OFFER_EFFECTIVE_DATE"], "дата не указана"), + "LEGAL_NAME": _field(s["LEGAL_NAME"], "название/ФИО не указано"), + "INN": _field(s["LEGAL_INN"], "ИНН не указан"), + "BOT_USERNAME": _field(f"@{BOT_USERNAME}" if BOT_USERNAME else "", "бот не указан"), + "REFUND_HOURS": html.escape(s["REFUND_HOURS"]) if s["REFUND_HOURS"] else "24", + "SUPPORT_CONTACT": _field(s["SUPPORT_CONTACT"], "контакт не указан"), + "SUPPORT_EMAIL": _field(s["SUPPORT_EMAIL"], "email не указан"), + } + for token, value in replacements.items(): + content = content.replace("{{" + token + "}}", value) + return content diff --git a/payments.py b/payments.py index dac08aa..441175d 100644 --- a/payments.py +++ b/payments.py @@ -62,6 +62,11 @@ def create_yookassa_payment(payment_id: str, amount_rub: int, description: str) return external_id, pay_url +def validate_yookassa_credentials(shop_id: str, secret_key: str) -> dict: + auth = base64.b64encode(f"{shop_id}:{secret_key}".encode()).decode() + return _get_json("https://api.yookassa.ru/v3/me", {"Authorization": f"Basic {auth}"}) + + def verify_yookassa_notification(body: dict) -> bool: return body.get("event") == "payment.succeeded" and "object" in body diff --git a/site/offer.html b/site/offer.html index 2601650..3d4cd4d 100644 --- a/site/offer.html +++ b/site/offer.html @@ -34,12 +34,12 @@
← На главную

Публичная оферта

-

Действует с [дата]

+

Действует с {{EFFECTIVE_DATE}}

-

Настоящий документ является публичной офертой [ФИО ИП / наименование самозанятого или юрлица], [ИНН] (далее — «Исполнитель»), адресованной любому дееспособному физическому лицу (далее — «Клиент»), на заключение договора о предоставлении доступа к VPN-сервису на условиях, указанных ниже. Оплата услуги означает полное и безоговорочное принятие условий оферты (акцепт).

+

Настоящий документ является публичной офертой {{LEGAL_NAME}}, {{INN}} (далее — «Исполнитель»), адресованной любому дееспособному физическому лицу (далее — «Клиент»), на заключение договора о предоставлении доступа к VPN-сервису на условиях, указанных ниже. Оплата услуги означает полное и безоговорочное принятие условий оферты (акцепт).

1. Предмет договора

-

Исполнитель предоставляет Клиенту доступ к серверу VPN (VLESS/Hysteria2) на срок, соответствующий выбранному тарифу, через Telegram-бота [@ваш_бот]. Доступ выдаётся автоматически после подтверждения оплаты.

+

Исполнитель предоставляет Клиенту доступ к серверу VPN (VLESS/Hysteria2) на срок, соответствующий выбранному тарифу, через Telegram-бота {{BOT_USERNAME}}. Доступ выдаётся автоматически после подтверждения оплаты.

2. Стоимость и порядок оплаты

    @@ -52,7 +52,7 @@

    Доступ предоставляется на срок выбранного тарифа (от 7 дней до 1 года) и автоматически прекращается по истечении срока. Продление — отдельной оплатой, автосписание не производится.

    4. Возврат средств

    -

    Возврат возможен в течение [N] часов с момента оплаты, если доступ ни разу не был использован (не было подключений к серверу), — по обращению в поддержку [контакт]. После начала использования услуга считается оказанной.

    +

    Возврат возможен в течение {{REFUND_HOURS}} часов с момента оплаты, если доступ ни разу не был использован (не было подключений к серверу), — по обращению в поддержку {{SUPPORT_CONTACT}}. После начала использования услуга считается оказанной.

    5. Права и обязанности сторон

      @@ -63,10 +63,10 @@

      6. Реквизиты Исполнителя

      - [ФИО / наименование]
      - ИНН [номер]
      - Email: [email]
      - Telegram: [контакт поддержки] + {{LEGAL_NAME}}
      + ИНН {{INN}}
      + Email: {{SUPPORT_EMAIL}}
      + Telegram: {{SUPPORT_CONTACT}}

diff --git a/site/privacy.html b/site/privacy.html index c903ab7..eb0fbc0 100644 --- a/site/privacy.html +++ b/site/privacy.html @@ -34,9 +34,9 @@
← На главную

Политика конфиденциальности

-

Действует с [дата]

+

Действует с {{EFFECTIVE_DATE}}

-

Настоящая политика описывает, какие данные собирает и как обрабатывает [ФИО ИП / наименование] (далее — «Оператор») при использовании Telegram-бота и сайта MBS Panel.

+

Настоящая политика описывает, какие данные собирает и как обрабатывает {{LEGAL_NAME}} (далее — «Оператор») при использовании Telegram-бота и сайта MBS Panel.

1. Какие данные собираются

    @@ -58,13 +58,13 @@

    Данные хранятся на серверах Оператора и не передаются третьим лицам, кроме платёжных провайдеров (ЮKassa, Platega) в объёме, необходимом для обработки оплаты, и в случаях, прямо предусмотренных законодательством РФ.

    4. Права пользователя

    -

    Пользователь вправе запросить удаление своих данных и прекращение обработки, обратившись на [email/контакт поддержки]. Удаление данных влечёт прекращение доступа к активным подпискам.

    +

    Пользователь вправе запросить удаление своих данных и прекращение обработки, обратившись на {{SUPPORT_EMAIL}} или {{SUPPORT_CONTACT}}. Удаление данных влечёт прекращение доступа к активным подпискам.

    5. Контакты

    - [ФИО / наименование]
    - Email: [email]
    - Telegram: [контакт поддержки] + {{LEGAL_NAME}}
    + Email: {{SUPPORT_EMAIL}}
    + Telegram: {{SUPPORT_CONTACT}}