payments: Platega + YooKassa integration, opt-in via PAYMENTS_ENABLED

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Savsis? 2026-09-10 21:44:45 +05:00
parent c9955b2774
commit 5b30d1f148
6 changed files with 326 additions and 8 deletions

View file

@ -31,3 +31,26 @@ XRAY_SHORT_ID_XHTTP=
# Public hostname clients connect to for the local node (A record -> this server) # Public hostname clients connect to for the local node (A record -> this server)
DE1_ADDRESS=de1.example.com DE1_ADDRESS=de1.example.com
# Payments — off by default, bot keeps handing out free subscriptions on button press.
# Flip to true only once at least one provider below is configured and its webhook is live.
PAYMENTS_ENABLED=false
# Prices in RUB per plan (whole numbers). Only used when PAYMENTS_ENABLED=true.
PRICE_7D=150
PRICE_1M=399
PRICE_3M=999
PRICE_6M=1799
PRICE_1Y=2999
# ЮKassa — https://yookassa.ru, shop id + secret key from your account settings.
# Webhook to add in their dashboard: https://<PANEL_DOMAIN>/payments/webhook/yookassa
YOOKASSA_ENABLED=false
YOOKASSA_SHOP_ID=
YOOKASSA_SECRET_KEY=
# Platega — https://platega.io, merchant id + secret from your manager.
# Webhook to add in their dashboard: https://<PANEL_DOMAIN>/payments/webhook/platega
PLATEGA_ENABLED=false
PLATEGA_MERCHANT_ID=
PLATEGA_SECRET=

77
api.py
View file

@ -9,10 +9,11 @@ from fastapi.responses import HTMLResponse, PlainTextResponse, FileResponse
import db import db
import links import links
import nodeprov import nodeprov
import payments
import xray_manager import xray_manager
from config import ( from config import (
PLANS, PLANS_BY_CODE, SITE_DOMAIN, SUB_DOMAIN, PANEL_DOMAIN, PLANS, PLANS_BY_CODE, SITE_DOMAIN, SUB_DOMAIN, PANEL_DOMAIN,
ADMIN_PANEL_PASSWORD, BOT_USERNAME, ADMIN_PANEL_PASSWORD, BOT_USERNAME, BOT_TOKEN,
) )
db.init_db() db.init_db()
@ -251,6 +252,80 @@ def install_script(token: str):
return nodeprov.render_install_script(node) return nodeprov.render_install_script(node)
def _tg_send_message(tg_id: int, text: str):
import urllib.request
url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage"
data = json.dumps({"chat_id": tg_id, "text": text, "parse_mode": "HTML"}).encode()
req = urllib.request.Request(url, data=data, method="POST", headers={"Content-Type": "application/json"})
try:
urllib.request.urlopen(req, timeout=10)
except Exception:
pass
def _grant_paid_subscription(payment_id: str):
payment = db.mark_payment_paid(payment_id)
if not payment:
return
plan = PLANS_BY_CODE.get(payment["plan"])
node = db.get_node(payment["node"])
if not plan or not node:
return
sub = db.create_subscription(payment["tg_id"], payment["node"], plan["days"], payment["plan"], source="payment")
xray_manager.add_client_to_node(node, sub["uuid"], email=sub["uuid"])
user = db.get_or_create_user(payment["tg_id"], None)
_tg_send_message(
payment["tg_id"],
f"<b>Оплата получена</b>\n\n"
f"Сервер: {node['label']}\n"
f"Срок: {plan['label']} — до {sub['expires_at'][:10]}\n\n"
f"Ссылка-подписка:\nhttps://{SUB_DOMAIN}/sub/{user['token']}",
)
@app.post("/payments/webhook/yookassa")
async def yookassa_webhook(request: Request):
body = await request.json()
if not payments.verify_yookassa_notification(body):
raise HTTPException(400, "unexpected event")
obj = body.get("object", {})
if obj.get("status") != "succeeded":
return {"ok": True}
payment_id = (obj.get("metadata") or {}).get("payment_id")
if not payment_id:
raise HTTPException(400, "missing payment_id")
_grant_paid_subscription(payment_id)
return {"ok": True}
@app.post("/payments/webhook/platega")
async def platega_webhook(request: Request):
raw = await request.body()
signature = request.headers.get("x-signature") or request.headers.get("signature") or ""
if not payments.verify_platega_signature(raw, signature):
raise HTTPException(401, "bad signature")
body = json.loads(raw)
status = (body.get("status") or "").lower()
payment_id = body.get("id") or body.get("paymentId")
if status not in ("succeeded", "success", "paid") or not payment_id:
return {"ok": True}
_grant_paid_subscription(payment_id)
return {"ok": True}
@app.get("/pay/done", response_class=HTMLResponse)
def pay_done():
return (
"<!doctype html><html lang='ru'><head><meta charset='utf-8'>"
"<meta name='viewport' content='width=device-width, initial-scale=1'>"
"<title>Оплата</title></head>"
"<body style='background:#0a0b0f;color:#eceef2;font-family:sans-serif;"
"display:flex;align-items:center;justify-content:center;min-height:100vh;text-align:center'>"
"<div><h2>Спасибо!</h2><p>Возвращайся в Telegram — подписка придёт туда автоматически "
"в течение минуты после подтверждения оплаты.</p></div></body></html>"
)
@app.post("/nodes/register/{token}") @app.post("/nodes/register/{token}")
async def register_node(token: str, request: Request): async def register_node(token: str, request: Request):
node = db.get_node_by_token(token) node = db.get_node_by_token(token)

50
bot.py
View file

@ -9,8 +9,9 @@ from aiogram.enums import ParseMode
import db import db
import links import links
import payments
import xray_manager import xray_manager
from config import BOT_TOKEN, ADMIN_IDS, PLANS, PLANS_BY_CODE, SUB_DOMAIN, SITE_DOMAIN from config import BOT_TOKEN, ADMIN_IDS, PLANS, PLANS_BY_CODE, SUB_DOMAIN, SITE_DOMAIN, PAYMENTS_ENABLED
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
log = logging.getLogger("mbs-bot") log = logging.getLogger("mbs-bot")
@ -49,7 +50,8 @@ def nodes_kb(prefix: str) -> InlineKeyboardMarkup:
def plans_kb(prefix: str, node_code: str) -> InlineKeyboardMarkup: def plans_kb(prefix: str, node_code: str) -> InlineKeyboardMarkup:
rows = [] rows = []
for p in PLANS: for p in PLANS:
rows.append([InlineKeyboardButton(text=p["label"], callback_data=f"{prefix}:{node_code}:{p['code']}")]) label = f"{p['label']} — {p['price']} ₽" if PAYMENTS_ENABLED and p["price"] > 0 else p["label"]
rows.append([InlineKeyboardButton(text=label, callback_data=f"{prefix}:{node_code}:{p['code']}")])
rows.append([InlineKeyboardButton(text="Назад", callback_data="menu:get")]) rows.append([InlineKeyboardButton(text="Назад", callback_data="menu:get")])
return InlineKeyboardMarkup(inline_keyboard=rows) return InlineKeyboardMarkup(inline_keyboard=rows)
@ -145,10 +147,27 @@ async def cb_node(cb: CallbackQuery):
await cb.answer() await cb.answer()
def providers_kb(node_code: str, plan_code: str) -> InlineKeyboardMarkup:
rows = []
for p in payments.available_providers():
rows.append([InlineKeyboardButton(text=payments.PROVIDER_NAMES[p], callback_data=f"pay:{p}:{node_code}:{plan_code}")])
rows.append([InlineKeyboardButton(text="Назад", callback_data=f"node:{node_code}")])
return InlineKeyboardMarkup(inline_keyboard=rows)
@dp.callback_query(F.data.startswith("plan:")) @dp.callback_query(F.data.startswith("plan:"))
async def cb_plan(cb: CallbackQuery): async def cb_plan(cb: CallbackQuery):
_, node_code, plan_code = cb.data.split(":") _, node_code, plan_code = cb.data.split(":")
plan = PLANS_BY_CODE[plan_code] plan = PLANS_BY_CODE[plan_code]
db.get_or_create_user(cb.from_user.id, cb.from_user.username)
if PAYMENTS_ENABLED and plan["price"] > 0 and payments.available_providers():
await cb.message.edit_text(
f"<b>{plan['label']}</b> — {plan['price']} ₽\n\nВыбери способ оплаты:",
reply_markup=providers_kb(node_code, plan_code),
)
return await cb.answer()
user = db.get_or_create_user(cb.from_user.id, cb.from_user.username) user = db.get_or_create_user(cb.from_user.id, cb.from_user.username)
sub = db.create_subscription(cb.from_user.id, node_code, plan["days"], plan_code, source="bot") sub = db.create_subscription(cb.from_user.id, node_code, plan["days"], plan_code, source="bot")
node_row = db.get_node(node_code) node_row = db.get_node(node_code)
@ -168,6 +187,33 @@ async def cb_plan(cb: CallbackQuery):
await cb.answer("Подписка выдана") await cb.answer("Подписка выдана")
@dp.callback_query(F.data.startswith("pay:"))
async def cb_pay(cb: CallbackQuery):
_, provider, node_code, plan_code = cb.data.split(":")
plan = PLANS_BY_CODE[plan_code]
node_row = db.get_node(node_code)
payment_id = payments.new_payment_id()
db.create_payment(payment_id, cb.from_user.id, node_code, plan_code, provider, plan["price"])
try:
external_id, pay_url = payments.create_payment_link(
provider, payment_id, plan["price"], f"MBS Panel — {node_row['label']}, {plan['label']}",
)
except Exception:
log.exception("payment creation failed")
db.mark_payment_failed(payment_id)
return await cb.answer("Не получилось создать платёж, попробуй позже", show_alert=True)
db.set_payment_external(payment_id, external_id, pay_url)
kb = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="Оплатить", url=pay_url)],
[InlineKeyboardButton(text="Назад", callback_data=f"plan:{node_code}:{plan_code}")],
])
await cb.message.edit_text(
f"Счёт на {plan['price']} ₽ создан.\nПосле оплаты подписка выдастся автоматически.",
reply_markup=kb,
)
await cb.answer()
@dp.callback_query(F.data == "menu:mysub") @dp.callback_query(F.data == "menu:mysub")
async def cb_mysub(cb: CallbackQuery): async def cb_mysub(cb: CallbackQuery):
user = db.get_or_create_user(cb.from_user.id, cb.from_user.username) user = db.get_or_create_user(cb.from_user.id, cb.from_user.username)

View file

@ -98,10 +98,20 @@ DE1_TRANSPORTS = [
] ]
PLANS = [ PLANS = [
{"code": "7d", "label": "7 дней", "days": 7}, {"code": "7d", "label": "7 дней", "days": 7, "price": int(env("PRICE_7D", "150"))},
{"code": "1m", "label": "1 месяц", "days": 30}, {"code": "1m", "label": "1 месяц", "days": 30, "price": int(env("PRICE_1M", "399"))},
{"code": "3m", "label": "3 месяца", "days": 90}, {"code": "3m", "label": "3 месяца", "days": 90, "price": int(env("PRICE_3M", "999"))},
{"code": "6m", "label": "6 месяцев", "days": 180}, {"code": "6m", "label": "6 месяцев", "days": 180, "price": int(env("PRICE_6M", "1799"))},
{"code": "1y", "label": "1 год", "days": 365}, {"code": "1y", "label": "1 год", "days": 365, "price": int(env("PRICE_1Y", "2999"))},
] ]
PLANS_BY_CODE = {p["code"]: p for p in PLANS} PLANS_BY_CODE = {p["code"]: p for p in PLANS}
PAYMENTS_ENABLED = env("PAYMENTS_ENABLED", "false").lower() == "true"
YOOKASSA_ENABLED = env("YOOKASSA_ENABLED", "false").lower() == "true"
YOOKASSA_SHOP_ID = env("YOOKASSA_SHOP_ID", "")
YOOKASSA_SECRET_KEY = env("YOOKASSA_SECRET_KEY", "")
PLATEGA_ENABLED = env("PLATEGA_ENABLED", "false").lower() == "true"
PLATEGA_MERCHANT_ID = env("PLATEGA_MERCHANT_ID", "")
PLATEGA_SECRET = env("PLATEGA_SECRET", "")

68
db.py
View file

@ -40,6 +40,20 @@ CREATE TABLE IF NOT EXISTS admin_sessions (
expires_at TEXT NOT NULL expires_at TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS payments (
id TEXT PRIMARY KEY,
tg_id INTEGER NOT NULL,
node TEXT NOT NULL,
plan TEXT NOT NULL,
provider TEXT NOT NULL,
external_id TEXT,
amount INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
pay_url TEXT,
created_at TEXT NOT NULL,
paid_at TEXT
);
CREATE TABLE IF NOT EXISTS nodes ( CREATE TABLE IF NOT EXISTS nodes (
code TEXT PRIMARY KEY, code TEXT PRIMARY KEY,
label TEXT NOT NULL, label TEXT NOT NULL,
@ -342,3 +356,57 @@ def stats():
"gifts_created": gifts_created, "gifts_created": gifts_created,
"gifts_used": gifts_used, "gifts_used": gifts_used,
} }
def create_payment(payment_id: str, tg_id: int, node: str, plan: str, provider: str, amount: int):
with get_conn() as conn:
conn.execute(
"INSERT INTO payments (id, tg_id, node, plan, provider, amount, status, created_at) "
"VALUES (?,?,?,?,?,?, 'pending', ?)",
(payment_id, tg_id, node, plan, provider, amount, now_iso()),
)
return get_payment(payment_id)
def get_payment(payment_id: str):
with get_conn() as conn:
row = conn.execute("SELECT * FROM payments WHERE id=?", (payment_id,)).fetchone()
return dict(row) if row else None
def set_payment_external(payment_id: str, external_id: str, pay_url: str):
with get_conn() as conn:
conn.execute(
"UPDATE payments SET external_id=?, pay_url=? WHERE id=?",
(external_id, pay_url, payment_id),
)
return get_payment(payment_id)
def mark_payment_paid(payment_id: str):
with get_conn() as conn:
row = conn.execute("SELECT status FROM payments WHERE id=?", (payment_id,)).fetchone()
if not row or row["status"] == "paid":
return None
conn.execute(
"UPDATE payments SET status='paid', paid_at=? WHERE id=?",
(now_iso(), payment_id),
)
return get_payment(payment_id)
def mark_payment_failed(payment_id: str):
with get_conn() as conn:
conn.execute(
"UPDATE payments SET status='failed' WHERE id=? AND status='pending'",
(payment_id,),
)
return get_payment(payment_id)
def list_payments(limit: int = 200):
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM payments ORDER BY created_at DESC LIMIT ?", (limit,)
).fetchall()
return [dict(r) for r in rows]

96
payments.py Normal file
View file

@ -0,0 +1,96 @@
import base64
import hashlib
import hmac
import json
import secrets
import urllib.request
from config import (
PANEL_DOMAIN,
YOOKASSA_ENABLED, YOOKASSA_SHOP_ID, YOOKASSA_SECRET_KEY,
PLATEGA_ENABLED, PLATEGA_MERCHANT_ID, PLATEGA_SECRET,
)
PROVIDER_NAMES = {"yookassa": "ЮKassa", "platega": "Platega"}
def available_providers() -> list[str]:
providers = []
if YOOKASSA_ENABLED:
providers.append("yookassa")
if PLATEGA_ENABLED:
providers.append("platega")
return providers
def new_payment_id() -> str:
return secrets.token_hex(16)
def _post_json(url: str, body: dict, headers: dict, timeout: int = 15) -> dict:
data = json.dumps(body).encode()
req = urllib.request.Request(url, data=data, method="POST", headers=headers)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode())
def create_yookassa_payment(payment_id: str, amount_rub: int, description: str) -> str:
auth = base64.b64encode(f"{YOOKASSA_SHOP_ID}:{YOOKASSA_SECRET_KEY}".encode()).decode()
data = _post_json(
"https://api.yookassa.ru/v3/payments",
{
"amount": {"value": f"{amount_rub}.00", "currency": "RUB"},
"confirmation": {"type": "redirect", "return_url": f"https://{PANEL_DOMAIN}/pay/done"},
"capture": True,
"description": description,
"metadata": {"payment_id": payment_id},
},
{
"Content-Type": "application/json",
"Authorization": f"Basic {auth}",
"Idempotence-Key": payment_id,
},
)
external_id = data["id"]
pay_url = data["confirmation"]["confirmation_url"]
return external_id, pay_url
def verify_yookassa_notification(body: dict) -> bool:
return body.get("event") == "payment.succeeded" and "object" in body
def create_platega_payment(payment_id: str, amount_rub: int, description: str) -> str:
data = _post_json(
"https://app.platega.io/transaction/process",
{
"paymentMethod": 2,
"id": payment_id,
"paymentDetails": {"amount": amount_rub, "currency": "RUB"},
"description": description,
"return": f"https://{PANEL_DOMAIN}/pay/done",
},
{
"Content-Type": "application/json",
"X-MerchantId": PLATEGA_MERCHANT_ID,
"X-Secret": PLATEGA_SECRET,
},
)
external_id = data.get("id") or data.get("transactionId")
pay_url = data.get("redirectUrl") or data.get("url")
return external_id, pay_url
def verify_platega_signature(raw_body: bytes, signature: str) -> bool:
if not signature:
return False
expected = hmac.new(PLATEGA_SECRET.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
def create_payment_link(provider: str, payment_id: str, amount_rub: int, description: str):
if provider == "yookassa":
return create_yookassa_payment(payment_id, amount_rub, description)
if provider == "platega":
return create_platega_payment(payment_id, amount_rub, description)
raise ValueError(f"unknown provider: {provider}")