payments: status verification (check + auto-reconcile pending), admin Payments view

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Savsis? 2026-09-10 22:46:55 +05:00
parent 44d738f8a4
commit f5f8f21f5d
4 changed files with 162 additions and 1 deletions

View file

@ -104,6 +104,7 @@
.nav-item.active { background: var(--accent-dim); color: var(--accent); }
.nav-item.active svg { opacity: 1; }
.sidebar-footer { margin-top: auto; padding: 10px 0 0; }
.version-tag { text-align: center; font-size: 11px; color: var(--muted2); margin-top: 10px; font-family: "Fira Mono", ui-monospace, monospace; }
.main { padding: 30px 40px; max-width: 1120px; }
.page-title { font-size: 20px; font-weight: 600; margin: 0 0 4px; letter-spacing: -0.01em; }
@ -281,8 +282,10 @@
<div class="nav-item" data-view="gifts" onclick="showView('gifts')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="8" width="18" height="13" rx="1.5"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="12" y1="8" x2="12" y2="21"/><path d="M12 8c-1.2 0-2.3-1.3-2.3-2.6C9.7 4 10.6 3 11.6 3c1.4 0 2.4 2 .4 5"/><path d="M12 8c1.2 0 2.3-1.3 2.3-2.6C14.3 4 13.4 3 12.4 3c-1.4 0-2.4 2-.4 5"/></svg>Гифт-коды</div>
<div class="nav-item" data-view="nodes" onclick="showView('nodes')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="6" rx="1.5"/><rect x="3" y="14" width="18" height="6" rx="1.5"/><line x1="7" y1="7" x2="7.01" y2="7"/><line x1="7" y1="17" x2="7.01" y2="17"/></svg>Ноды</div>
<div class="nav-item" data-view="traffic" onclick="showView('traffic')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3,13 8,13 10,7 14,19 16,13 21,13"/></svg>Трафик</div>
<div class="nav-item" data-view="payments" onclick="showView('payments')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></svg>Платежи</div>
<div class="sidebar-footer">
<button class="btn ghost" style="width:100%" onclick="logout()">Выйти</button>
<div class="version-tag">MBS Panel v1.0.0 · <a href="https://github.com/devsavsis/mbs-panel/releases/latest" target="_blank" style="color:inherit">обновления</a></div>
</div>
</div>
@ -468,6 +471,14 @@
</tr></thead><tbody id="traffic-body"></tbody></table></div>
</div>
</div>
<div id="view-payments" class="view">
<div class="page-title">Платежи</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>
</tr></thead><tbody id="payments-body"></tbody></table></div>
</div>
</div>
</div>
@ -520,6 +531,7 @@ function showView(name) {
if (name === "gifts") loadGifts();
if (name === "nodes") loadNodes();
if (name === "traffic") loadTraffic();
if (name === "payments") loadPayments();
}
const COUNTRIES = [
@ -736,6 +748,35 @@ async function resetTraffic(uuid, btn) {
}
}
function paymentStatusBadge(status) {
if (status === "paid") return '<span class="badge ok">оплачен</span>';
if (status === "failed") return '<span class="badge bad">не прошёл</span>';
return '<span class="badge warn">ожидание</span>';
}
async function loadPayments() {
const rows = await api("/admin/api/payments");
const body = document.getElementById("payments-body");
body.innerHTML = rows.length ? rows.map((p, i) => `
<tr ${rowAttr(i)}>
<td>tg${p.tg_id}</td><td>${esc(p.node_label)}</td><td>${esc(p.plan_label)}</td>
<td>${esc(p.provider_label)}</td><td>${p.amount} ₽</td><td>${fmtDate(p.created_at)}</td>
<td>${paymentStatusBadge(p.status)}</td>
<td>${p.status === "pending" ? `<button class="muted-btn" onclick="checkPayment('${p.id}', this)">Проверить</button>` : ""}</td>
</tr>
`).join("") : '<tr><td colspan="8"><div class="empty">Пока нет платежей</div></td></tr>';
}
async function checkPayment(id, btn) {
btn.disabled = true;
btn.textContent = "…";
try {
await api(`/admin/api/payments/${id}/check`, { method: "POST" });
} finally {
loadPayments();
}
}
function fmtDate(iso) { return iso ? iso.slice(0, 10) : "—"; }
function statusBadge(active, daysLeft) {
if (!active) return '<span class="badge bad">истекла</span>';

48
api.py
View file

@ -109,6 +109,8 @@ SUB_PAGE_TEMPLATE = """<!doctype html>
}}
@keyframes fadeIn {{ to {{ opacity: 1; }} }}
.thanks {{ font-size: 13px; color: var(--muted); line-height: 1.5; }}
.thanks a {{ color: var(--accent); text-decoration: none; }}
.thanks a:hover {{ text-decoration: underline; }}
.qr-box {{
display: flex; justify-content: center; margin-bottom: 22px;
opacity: 0; animation: fadeIn 0.5s var(--ease) 0.3s forwards;
@ -130,7 +132,7 @@ SUB_PAGE_TEMPLATE = """<!doctype html>
<a class="btn secondary" href="{sub_url}">Открыть ссылку подписки</a>
<div class="qr-box" id="qr"></div>
<div class="link-box">{sub_url}</div>
<div class="thanks">Спасибо, что пользуетесь MBS Panel.<br>Если Happ не установлен — скачай его в App Store или Google Play.</div>
<div class="thanks">Спасибо, что пользуетесь MBS Panel.<br>Нет Happ? Скачай: <a href="https://apps.apple.com/us/app/happ-proxy-utility/id6504287215" target="_blank">App Store</a> · <a href="https://play.google.com/store/apps/details?id=com.happproxy" target="_blank">Google Play</a></div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<script>
@ -303,6 +305,50 @@ def _grant_paid_subscription(payment_id: str):
)
def _check_and_reconcile_payment(payment: dict) -> str:
if not payment.get("external_id"):
return payment["status"]
try:
status = payments.check_payment_status(payment["provider"], payment["external_id"])
except Exception:
return payment["status"]
if status in payments.PAID_STATUSES:
_grant_paid_subscription(payment["id"])
return "paid"
if status in payments.FAILED_STATUSES:
db.mark_payment_failed(payment["id"])
return "failed"
return payment["status"]
@app.get("/admin/api/payments")
def admin_list_payments(request: Request):
require_admin(request)
out = []
for p in db.list_payments():
node = db.get_node(p["node"])
plan = PLANS_BY_CODE.get(p["plan"])
out.append({
**p,
"node_label": node["label"] if node else p["node"],
"plan_label": plan["label"] if plan else p["plan"],
"provider_label": payments.PROVIDER_NAMES.get(p["provider"], p["provider"]),
})
return out
@app.post("/admin/api/payments/{payment_id}/check")
def admin_check_payment(payment_id: str, request: Request):
require_admin(request)
payment = db.get_payment(payment_id)
if not payment:
raise HTTPException(404, "not found")
if payment["status"] != "pending":
return {"status": payment["status"]}
status = _check_and_reconcile_payment(payment)
return {"status": status}
@app.post("/payments/webhook/yookassa")
async def yookassa_webhook(request: Request):
body = await request.json()

39
bot.py
View file

@ -319,12 +319,51 @@ async def cb_admin_sync(cb: CallbackQuery):
await cb.answer()
async def reconcile_pending_payments():
if not PAYMENTS_ENABLED:
return
for payment in db.list_payments():
if payment["status"] != "pending" or not payment.get("external_id"):
continue
try:
status = payments.check_payment_status(payment["provider"], payment["external_id"])
except Exception:
continue
if status in payments.PAID_STATUSES:
granted = db.mark_payment_paid(payment["id"])
if not granted:
continue
plan = PLANS_BY_CODE.get(payment["plan"])
node_row = db.get_node(payment["node"])
if not plan or not node_row:
continue
sub = db.create_subscription(payment["tg_id"], payment["node"], plan["days"], payment["plan"], source="payment")
xray_manager.add_client_to_node(node_row, sub["uuid"], email=sub["uuid"])
user = db.get_or_create_user(payment["tg_id"], None)
try:
await bot.send_message(
payment["tg_id"],
f"<b>Оплата получена</b>\n\n"
f"Сервер: {node_row['label']}\n"
f"Срок: {plan['label']} — до {sub['expires_at'][:10]}\n\n"
f"Ссылка-подписка:\n{sub_url_for(user['token'])}",
)
except Exception:
log.exception("failed to notify user about payment")
elif status in payments.FAILED_STATUSES:
db.mark_payment_failed(payment["id"])
async def periodic_sync():
while True:
try:
xray_manager.sync_all()
except Exception:
log.exception("periodic sync failed")
try:
await reconcile_pending_payments()
except Exception:
log.exception("payment reconciliation failed")
await asyncio.sleep(90)

View file

@ -34,6 +34,12 @@ def _post_json(url: str, body: dict, headers: dict, timeout: int = 15) -> dict:
return json.loads(resp.read().decode())
def _get_json(url: str, headers: dict, timeout: int = 15) -> dict:
req = urllib.request.Request(url, method="GET", 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(
@ -60,6 +66,15 @@ def verify_yookassa_notification(body: dict) -> bool:
return body.get("event") == "payment.succeeded" and "object" in body
def check_yookassa_payment(external_id: str) -> str:
auth = base64.b64encode(f"{YOOKASSA_SHOP_ID}:{YOOKASSA_SECRET_KEY}".encode()).decode()
data = _get_json(
f"https://api.yookassa.ru/v3/payments/{external_id}",
{"Authorization": f"Basic {auth}"},
)
return data.get("status", "")
def create_platega_payment(payment_id: str, amount_rub: int, description: str) -> str:
data = _post_json(
"https://app.platega.io/transaction/process",
@ -88,9 +103,29 @@ def verify_platega_signature(raw_body: bytes, signature: str) -> bool:
return hmac.compare_digest(expected, signature)
def check_platega_payment(external_id: str) -> str:
data = _get_json(
f"https://app.platega.io/transaction/{external_id}",
{"X-MerchantId": PLATEGA_MERCHANT_ID, "X-Secret": PLATEGA_SECRET},
)
return data.get("status", "")
PAID_STATUSES = {"succeeded", "CONFIRMED"}
FAILED_STATUSES = {"canceled", "CANCELED", "CHARGEBACKED"}
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}")
def check_payment_status(provider: str, external_id: str) -> str:
if provider == "yookassa":
return check_yookassa_payment(external_id)
if provider == "platega":
return check_platega_payment(external_id)
raise ValueError(f"unknown provider: {provider}")