feat: payments setup wizard — legal pages + YooKassa keys from the admin UI
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 <noreply@anthropic.com>
This commit is contained in:
parent
3bd38103c3
commit
6311532c45
6 changed files with 249 additions and 19 deletions
121
admin.html
121
admin.html
|
|
@ -502,9 +502,53 @@
|
||||||
<div id="view-payments" class="view">
|
<div id="view-payments" class="view">
|
||||||
<div class="page-title">Платежи</div>
|
<div class="page-title">Платежи</div>
|
||||||
<div class="page-sub">ЮKassa / Platega — история и статус, с проверкой на стороне провайдера при пропущенном вебхуке</div>
|
<div class="page-sub">ЮKassa / Platega — история и статус, с проверкой на стороне провайдера при пропущенном вебхуке</div>
|
||||||
<div class="table-wrap"><table><thead><tr>
|
|
||||||
<th>Пользователь</th><th>Сервер</th><th>Тариф</th><th>Провайдер</th><th>Сумма</th><th>Создан</th><th>Статус</th><th></th>
|
<div class="section">
|
||||||
</tr></thead><tbody id="payments-body"></tbody></table></div>
|
<div class="section-head"><h2>Настройка приёма платежей</h2></div>
|
||||||
|
<p class="page-sub" style="margin-bottom:16px">Заполни один раз — панель сама соберёт из этого публичную оферту и политику конфиденциальности (обязательны для подключения ЮKassa) на своих страницах, готовых к показу клиентам.</p>
|
||||||
|
|
||||||
|
<div class="doc-block" style="margin-bottom:20px">
|
||||||
|
<h2>Как это работает</h2>
|
||||||
|
<p>1. Заполни реквизиты ниже (кто ты для закона — самозанятый/ИП/ООО, ИНН, контакты). Это те же данные, что ЮKassa попросит при регистрации магазина.</p>
|
||||||
|
<p>2. Подключи ЮKassa: заведи магазин на <a href="https://yookassa.ru" target="_blank">yookassa.ru</a>, в личном кабинете возьми <b>shop_id</b> и <b>секретный ключ</b> (Настройки → Ключи API), вставь сюда. Панель сразу проверит их и сохранит.</p>
|
||||||
|
<p>3. Ссылки на готовые оферту и политику (<code>https://{домен}/offer</code>, <code>/privacy</code>) — дай их ЮKassa при регистрации магазина, она их обязательно спросит.</p>
|
||||||
|
<p class="muted">Самозанятым для приёма платежей от физлиц регистрация магазина в ЮKassa доступна напрямую по паспорту и ИНН, без онлайн-кассы — она уже встроена в сервис ЮKassa. ИП/ООО — обычная регистрация магазина.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style="font-size:14px;margin:0 0 12px">Реквизиты для документов</h3>
|
||||||
|
<div class="form-row">
|
||||||
|
<div><label class="f">Кто ты</label><div id="legal-type" class="dd"></div></div>
|
||||||
|
<div><label class="f" id="legal-name-label">ФИО</label><input type="text" id="legal-name" placeholder="Иванов Иван Иванович"></div>
|
||||||
|
<div><label class="f">ИНН</label><input type="text" id="legal-inn" placeholder="770123456789"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row" style="margin-top:12px">
|
||||||
|
<div><label class="f">Email поддержки</label><input type="text" id="legal-email" placeholder="support@example.com"></div>
|
||||||
|
<div><label class="f">Telegram-контакт поддержки</label><input type="text" id="legal-contact" placeholder="@support"></div>
|
||||||
|
<div><label class="f">Возврат в течение (часов)</label><input type="text" id="legal-refund" placeholder="24"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row" style="margin-top:12px">
|
||||||
|
<button class="btn" onclick="saveLegalSettings()">Сохранить реквизиты</button>
|
||||||
|
</div>
|
||||||
|
<div id="legal-result"></div>
|
||||||
|
<p class="check-hint">Страницы всегда доступны по ссылкам: <a href="/offer" target="_blank" id="legal-offer-link">/offer</a> · <a href="/privacy" target="_blank" id="legal-privacy-link">/privacy</a> — незаполненные поля показываются пометкой, что их надо указать, страница не ломается.</p>
|
||||||
|
|
||||||
|
<h3 style="font-size:14px;margin:24px 0 12px">ЮKassa — ключи API</h3>
|
||||||
|
<div id="yookassa-status" class="page-sub" style="margin-bottom:12px"></div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div><label class="f">shop_id</label><input type="text" id="yk-shop-id" placeholder="123456"></div>
|
||||||
|
<div><label class="f">Секретный ключ</label><input type="password" id="yk-secret-key" placeholder="live_..."></div>
|
||||||
|
<div style="flex:0"><label class="f"> </label><button class="btn" onclick="saveYookassaSettings()">Проверить и сохранить</button></div>
|
||||||
|
</div>
|
||||||
|
<p class="check-hint">Панель сама постучится в ЮKassa (<code>/v3/me</code>) и сохранит ключи только если они рабочие. После сохранения включаются приём оплаты и вебхуки; бот перезапустится сам, для самой панели (обработка вебхуков) один раз выполни на сервере <code>mbs restart</code>.</p>
|
||||||
|
<div id="yookassa-result"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section" style="margin-top:20px">
|
||||||
|
<div class="section-head"><h2>История</h2></div>
|
||||||
|
<div class="table-wrap"><table><thead><tr>
|
||||||
|
<th>Пользователь</th><th>Сервер</th><th>Тариф</th><th>Провайдер</th><th>Сумма</th><th>Создан</th><th>Статус</th><th></th>
|
||||||
|
</tr></thead><tbody id="payments-body"></tbody></table></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="view-docs" class="view">
|
<div id="view-docs" class="view">
|
||||||
|
|
@ -526,7 +570,7 @@
|
||||||
<div class="doc-block">
|
<div class="doc-block">
|
||||||
<h2>Пароль и безопасность</h2>
|
<h2>Пароль и безопасность</h2>
|
||||||
<p>Пароль админ-панели меняется командой <code>mbs pass</code> на сервере (без аргумента — сгенерит случайный). Панель физически откажется стартовать, если в <code>.env</code> стоит "change-me"/"admin"/что-то короче 8 символов — так что пропустить это не выйдет по-тихому.</p>
|
<p>Пароль админ-панели меняется командой <code>mbs pass</code> на сервере (без аргумента — сгенерит случайный). Панель физически откажется стартовать, если в <code>.env</code> стоит "change-me"/"admin"/что-то короче 8 символов — так что пропустить это не выйдет по-тихому.</p>
|
||||||
<p>Сессия логина живёт в httpOnly-куке, на <code>/admin/api/login</code> висит rate-limit (5 попыток в минуту с одного IP). SSH-доступ на сервер — сам по себе, панель на него не влияет; отдельно стоит подумать про отключение root-логина по паролю в пользу ключей, если этого ещё не сделано.</p>
|
<p>Сессия логина живёт в httpOnly-куке, опционально поверх пароля — 2FA (TOTP). На <code>/admin/api/login</code> и <code>/admin/api/login/totp</code> висит rate-limit (10 попыток за 15 минут на пароль, 10 за 5 минут на код — с одного IP). SSH-доступ на сервер — сам по себе, панель на него не влияет; отдельно стоит подумать про отключение root-логина по паролю в пользу ключей, если этого ещё не сделано.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="doc-block">
|
<div class="doc-block">
|
||||||
|
|
@ -685,7 +729,7 @@ function showView(name) {
|
||||||
if (name === "gifts") loadGifts();
|
if (name === "gifts") loadGifts();
|
||||||
if (name === "nodes") loadNodes();
|
if (name === "nodes") loadNodes();
|
||||||
if (name === "traffic") loadTraffic();
|
if (name === "traffic") loadTraffic();
|
||||||
if (name === "payments") loadPayments();
|
if (name === "payments") { loadPayments(); loadPaymentsSettings(); }
|
||||||
if (name === "settings") { loadBotSettings(); loadAdmins(); loadTotpStatus(); }
|
if (name === "settings") { loadBotSettings(); loadAdmins(); loadTotpStatus(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -860,6 +904,20 @@ function initDropdowns() {
|
||||||
});
|
});
|
||||||
DD.ucGrantNode = createDropdown("uc-grant-node", { options: [], placeholder: "Сервер" });
|
DD.ucGrantNode = createDropdown("uc-grant-node", { options: [], placeholder: "Сервер" });
|
||||||
DD.ucGrantPlan = createDropdown("uc-grant-plan", { 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 = {
|
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 = '<span class="badge ok">подключена</span> shop_id: ' + esc(ykRes.shop_id);
|
||||||
|
} else {
|
||||||
|
status.innerHTML = '<span class="badge bad">не настроена</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = '<p class="page-sub" style="margin-top:10px;color:var(--green)">Сохранено — страницы /offer и /privacy обновились сразу, без рестарта</p>';
|
||||||
|
} catch (e) {
|
||||||
|
result.innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--red)">Не получилось: ' + esc(e.message) + '</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = '<p class="page-sub" style="margin-top:10px">Проверяю ключи у ЮKassa…</p>';
|
||||||
|
try {
|
||||||
|
const res = await api("/admin/api/payments/yookassa-settings", { method: "POST", body: JSON.stringify({ shop_id, secret_key }) });
|
||||||
|
result.innerHTML = `<p class="page-sub" style="margin-top:10px;color:var(--green)">Ключи рабочие, сохранено.${res.restarted_bot ? " Бот перезапущен." : " Бот сам не перезапустился — выполни mbs restart."} Для приёма вебхуков панелью выполни на сервере <code>mbs restart</code>.</p>`;
|
||||||
|
document.getElementById("yk-secret-key").value = "";
|
||||||
|
loadPaymentsSettings();
|
||||||
|
} catch (e) {
|
||||||
|
result.innerHTML = '<p class="page-sub" style="margin-top:10px;color:var(--red)">Не получилось: ' + esc(e.message) + '</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadBotSettings() {
|
async function loadBotSettings() {
|
||||||
const data = await api("/admin/api/settings/bot");
|
const data = await api("/admin/api/settings/bot");
|
||||||
document.getElementById("settings-bot-username").textContent = "@" + data.username;
|
document.getElementById("settings-bot-username").textContent = "@" + data.username;
|
||||||
|
|
|
||||||
62
api.py
62
api.py
|
|
@ -12,6 +12,7 @@ from fastapi.responses import HTMLResponse, PlainTextResponse, FileResponse
|
||||||
|
|
||||||
import backup
|
import backup
|
||||||
import db
|
import db
|
||||||
|
import legal
|
||||||
import links
|
import links
|
||||||
import nodeprov
|
import nodeprov
|
||||||
import payments
|
import payments
|
||||||
|
|
@ -374,6 +375,57 @@ def admin_check_payment(payment_id: str, request: Request):
|
||||||
return {"status": status}
|
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")
|
@app.post("/payments/webhook/yookassa")
|
||||||
async def yookassa_webhook(request: Request):
|
async def yookassa_webhook(request: Request):
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
|
|
@ -994,3 +1046,13 @@ def root(request: Request):
|
||||||
@app.get("/admin")
|
@app.get("/admin")
|
||||||
def admin_page():
|
def admin_page():
|
||||||
return FileResponse(ADMIN_HTML_PATH, headers=_NO_CACHE)
|
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")
|
||||||
|
|
|
||||||
52
legal.py
Normal file
52
legal.py
Normal file
|
|
@ -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'<span class="fill">{html.escape(label)}</span>'
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
@ -62,6 +62,11 @@ def create_yookassa_payment(payment_id: str, amount_rub: int, description: str)
|
||||||
return external_id, pay_url
|
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:
|
def verify_yookassa_notification(body: dict) -> bool:
|
||||||
return body.get("event") == "payment.succeeded" and "object" in body
|
return body.get("event") == "payment.succeeded" and "object" in body
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,12 +34,12 @@
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<a class="back" href="/">← На главную</a>
|
<a class="back" href="/">← На главную</a>
|
||||||
<h1>Публичная оферта</h1>
|
<h1>Публичная оферта</h1>
|
||||||
<p class="updated">Действует с <span class="fill">[дата]</span></p>
|
<p class="updated">Действует с {{EFFECTIVE_DATE}}</p>
|
||||||
|
|
||||||
<p>Настоящий документ является публичной офертой <span class="fill">[ФИО ИП / наименование самозанятого или юрлица]</span>, <span class="fill">[ИНН]</span> (далее — «Исполнитель»), адресованной любому дееспособному физическому лицу (далее — «Клиент»), на заключение договора о предоставлении доступа к VPN-сервису на условиях, указанных ниже. Оплата услуги означает полное и безоговорочное принятие условий оферты (акцепт).</p>
|
<p>Настоящий документ является публичной офертой {{LEGAL_NAME}}, {{INN}} (далее — «Исполнитель»), адресованной любому дееспособному физическому лицу (далее — «Клиент»), на заключение договора о предоставлении доступа к VPN-сервису на условиях, указанных ниже. Оплата услуги означает полное и безоговорочное принятие условий оферты (акцепт).</p>
|
||||||
|
|
||||||
<h2>1. Предмет договора</h2>
|
<h2>1. Предмет договора</h2>
|
||||||
<p>Исполнитель предоставляет Клиенту доступ к серверу VPN (VLESS/Hysteria2) на срок, соответствующий выбранному тарифу, через Telegram-бота <span class="fill">[@ваш_бот]</span>. Доступ выдаётся автоматически после подтверждения оплаты.</p>
|
<p>Исполнитель предоставляет Клиенту доступ к серверу VPN (VLESS/Hysteria2) на срок, соответствующий выбранному тарифу, через Telegram-бота {{BOT_USERNAME}}. Доступ выдаётся автоматически после подтверждения оплаты.</p>
|
||||||
|
|
||||||
<h2>2. Стоимость и порядок оплаты</h2>
|
<h2>2. Стоимость и порядок оплаты</h2>
|
||||||
<ol>
|
<ol>
|
||||||
|
|
@ -52,7 +52,7 @@
|
||||||
<p>Доступ предоставляется на срок выбранного тарифа (от 7 дней до 1 года) и автоматически прекращается по истечении срока. Продление — отдельной оплатой, автосписание не производится.</p>
|
<p>Доступ предоставляется на срок выбранного тарифа (от 7 дней до 1 года) и автоматически прекращается по истечении срока. Продление — отдельной оплатой, автосписание не производится.</p>
|
||||||
|
|
||||||
<h2>4. Возврат средств</h2>
|
<h2>4. Возврат средств</h2>
|
||||||
<p>Возврат возможен в течение <span class="fill">[N]</span> часов с момента оплаты, если доступ ни разу не был использован (не было подключений к серверу), — по обращению в поддержку <span class="fill">[контакт]</span>. После начала использования услуга считается оказанной.</p>
|
<p>Возврат возможен в течение {{REFUND_HOURS}} часов с момента оплаты, если доступ ни разу не был использован (не было подключений к серверу), — по обращению в поддержку {{SUPPORT_CONTACT}}. После начала использования услуга считается оказанной.</p>
|
||||||
|
|
||||||
<h2>5. Права и обязанности сторон</h2>
|
<h2>5. Права и обязанности сторон</h2>
|
||||||
<ol>
|
<ol>
|
||||||
|
|
@ -63,10 +63,10 @@
|
||||||
|
|
||||||
<h2>6. Реквизиты Исполнителя</h2>
|
<h2>6. Реквизиты Исполнителя</h2>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
<span class="fill">[ФИО / наименование]</span><br>
|
{{LEGAL_NAME}}<br>
|
||||||
ИНН <span class="fill">[номер]</span><br>
|
ИНН {{INN}}<br>
|
||||||
Email: <span class="fill">[email]</span><br>
|
Email: {{SUPPORT_EMAIL}}<br>
|
||||||
Telegram: <span class="fill">[контакт поддержки]</span>
|
Telegram: {{SUPPORT_CONTACT}}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -34,9 +34,9 @@
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<a class="back" href="/">← На главную</a>
|
<a class="back" href="/">← На главную</a>
|
||||||
<h1>Политика конфиденциальности</h1>
|
<h1>Политика конфиденциальности</h1>
|
||||||
<p class="updated">Действует с <span class="fill">[дата]</span></p>
|
<p class="updated">Действует с {{EFFECTIVE_DATE}}</p>
|
||||||
|
|
||||||
<p>Настоящая политика описывает, какие данные собирает и как обрабатывает <span class="fill">[ФИО ИП / наименование]</span> (далее — «Оператор») при использовании Telegram-бота и сайта MBS Panel.</p>
|
<p>Настоящая политика описывает, какие данные собирает и как обрабатывает {{LEGAL_NAME}} (далее — «Оператор») при использовании Telegram-бота и сайта MBS Panel.</p>
|
||||||
|
|
||||||
<h2>1. Какие данные собираются</h2>
|
<h2>1. Какие данные собираются</h2>
|
||||||
<ul>
|
<ul>
|
||||||
|
|
@ -58,13 +58,13 @@
|
||||||
<p>Данные хранятся на серверах Оператора и не передаются третьим лицам, кроме платёжных провайдеров (ЮKassa, Platega) в объёме, необходимом для обработки оплаты, и в случаях, прямо предусмотренных законодательством РФ.</p>
|
<p>Данные хранятся на серверах Оператора и не передаются третьим лицам, кроме платёжных провайдеров (ЮKassa, Platega) в объёме, необходимом для обработки оплаты, и в случаях, прямо предусмотренных законодательством РФ.</p>
|
||||||
|
|
||||||
<h2>4. Права пользователя</h2>
|
<h2>4. Права пользователя</h2>
|
||||||
<p>Пользователь вправе запросить удаление своих данных и прекращение обработки, обратившись на <span class="fill">[email/контакт поддержки]</span>. Удаление данных влечёт прекращение доступа к активным подпискам.</p>
|
<p>Пользователь вправе запросить удаление своих данных и прекращение обработки, обратившись на {{SUPPORT_EMAIL}} или {{SUPPORT_CONTACT}}. Удаление данных влечёт прекращение доступа к активным подпискам.</p>
|
||||||
|
|
||||||
<h2>5. Контакты</h2>
|
<h2>5. Контакты</h2>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
<span class="fill">[ФИО / наименование]</span><br>
|
{{LEGAL_NAME}}<br>
|
||||||
Email: <span class="fill">[email]</span><br>
|
Email: {{SUPPORT_EMAIL}}<br>
|
||||||
Telegram: <span class="fill">[контакт поддержки]</span>
|
Telegram: {{SUPPORT_CONTACT}}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue