payments: Platega + YooKassa integration, opt-in via PAYMENTS_ENABLED
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
c9955b2774
commit
5b30d1f148
6 changed files with 326 additions and 8 deletions
23
.env.example
23
.env.example
|
|
@ -31,3 +31,26 @@ XRAY_SHORT_ID_XHTTP=
|
|||
|
||||
# Public hostname clients connect to for the local node (A record -> this server)
|
||||
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
77
api.py
|
|
@ -9,10 +9,11 @@ from fastapi.responses import HTMLResponse, PlainTextResponse, FileResponse
|
|||
import db
|
||||
import links
|
||||
import nodeprov
|
||||
import payments
|
||||
import xray_manager
|
||||
from config import (
|
||||
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()
|
||||
|
|
@ -251,6 +252,80 @@ def install_script(token: str):
|
|||
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}")
|
||||
async def register_node(token: str, request: Request):
|
||||
node = db.get_node_by_token(token)
|
||||
|
|
|
|||
50
bot.py
50
bot.py
|
|
@ -9,8 +9,9 @@ from aiogram.enums import ParseMode
|
|||
|
||||
import db
|
||||
import links
|
||||
import payments
|
||||
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)
|
||||
log = logging.getLogger("mbs-bot")
|
||||
|
|
@ -49,7 +50,8 @@ def nodes_kb(prefix: str) -> InlineKeyboardMarkup:
|
|||
def plans_kb(prefix: str, node_code: str) -> InlineKeyboardMarkup:
|
||||
rows = []
|
||||
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")])
|
||||
return InlineKeyboardMarkup(inline_keyboard=rows)
|
||||
|
||||
|
|
@ -145,10 +147,27 @@ async def cb_node(cb: CallbackQuery):
|
|||
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:"))
|
||||
async def cb_plan(cb: CallbackQuery):
|
||||
_, node_code, plan_code = cb.data.split(":")
|
||||
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)
|
||||
sub = db.create_subscription(cb.from_user.id, node_code, plan["days"], plan_code, source="bot")
|
||||
node_row = db.get_node(node_code)
|
||||
|
|
@ -168,6 +187,33 @@ async def cb_plan(cb: CallbackQuery):
|
|||
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")
|
||||
async def cb_mysub(cb: CallbackQuery):
|
||||
user = db.get_or_create_user(cb.from_user.id, cb.from_user.username)
|
||||
|
|
|
|||
20
config.py
20
config.py
|
|
@ -98,10 +98,20 @@ DE1_TRANSPORTS = [
|
|||
]
|
||||
|
||||
PLANS = [
|
||||
{"code": "7d", "label": "7 дней", "days": 7},
|
||||
{"code": "1m", "label": "1 месяц", "days": 30},
|
||||
{"code": "3m", "label": "3 месяца", "days": 90},
|
||||
{"code": "6m", "label": "6 месяцев", "days": 180},
|
||||
{"code": "1y", "label": "1 год", "days": 365},
|
||||
{"code": "7d", "label": "7 дней", "days": 7, "price": int(env("PRICE_7D", "150"))},
|
||||
{"code": "1m", "label": "1 месяц", "days": 30, "price": int(env("PRICE_1M", "399"))},
|
||||
{"code": "3m", "label": "3 месяца", "days": 90, "price": int(env("PRICE_3M", "999"))},
|
||||
{"code": "6m", "label": "6 месяцев", "days": 180, "price": int(env("PRICE_6M", "1799"))},
|
||||
{"code": "1y", "label": "1 год", "days": 365, "price": int(env("PRICE_1Y", "2999"))},
|
||||
]
|
||||
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
68
db.py
|
|
@ -40,6 +40,20 @@ CREATE TABLE IF NOT EXISTS admin_sessions (
|
|||
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 (
|
||||
code TEXT PRIMARY KEY,
|
||||
label TEXT NOT NULL,
|
||||
|
|
@ -342,3 +356,57 @@ def stats():
|
|||
"gifts_created": gifts_created,
|
||||
"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
96
payments.py
Normal 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}")
|
||||
Loading…
Add table
Add a link
Reference in a new issue