payments: status verification (check + auto-reconcile pending), admin Payments view
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
44d738f8a4
commit
f5f8f21f5d
4 changed files with 162 additions and 1 deletions
41
admin.html
41
admin.html
|
|
@ -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
48
api.py
|
|
@ -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
39
bot.py
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
35
payments.py
35
payments.py
|
|
@ -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}")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue