ParityPay SBP provider, unified ChromeEditModal, responsive container queries, perf audit

This commit is contained in:
Vladless
2026-04-30 17:21:53 +00:00
parent 55bebbd924
commit 3a7bf7430d
17 changed files with 2681 additions and 3 deletions
+805
View File
@@ -0,0 +1,805 @@
<!doctype html>
<html lang="ru" data-theme="dark">
<head>
<meta charset="utf-8" />
<title>SoloNet — Кабинет</title>
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{box-sizing:border-box}
html,body{margin:0;padding:0;height:100%}
:root{
--bg:#0A0A0A; --bg-2:#111; --bg-3:#181818;
--fg:#F2F2F2; --fg-dim:#7A7A7A; --fg-mute:#3A3A3A;
--line:#1F1F1F; --line-2:#2A2A2A;
--red:#FF2D2D; --green:#2DD46F; --yellow:#FFC107;
--mono:'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,monospace;
}
body{background:var(--bg);color:var(--fg);font-family:var(--mono);font-size:13px;line-height:1.5;-webkit-font-smoothing:antialiased}
::selection{background:var(--red);color:#fff}
button{font-family:inherit;cursor:pointer}
.app{display:grid;grid-template-columns:240px 1fr;min-height:100vh}
/* sidebar */
aside{border-right:1px solid var(--line);background:var(--bg);display:flex;flex-direction:column;position:sticky;top:0;height:100vh}
.brand{display:flex;align-items:center;gap:10px;padding:18px 20px;border-bottom:1px solid var(--line);font-weight:700;letter-spacing:.02em}
.brand-mark{width:18px;height:18px;border:1.5px solid var(--fg);position:relative;flex-shrink:0}
.brand-mark::before{content:"";position:absolute;inset:2.5px;background:var(--red)}
.brand-name{font-size:14px;letter-spacing:.04em}
.brand-ver{font-size:9px;color:var(--fg-dim);letter-spacing:.16em;margin-left:auto}
.conn-card{padding:14px 16px;border-bottom:1px solid var(--line);display:flex;flex-direction:column;gap:6px}
.conn-row{display:flex;justify-content:space-between;align-items:center;font-size:10px;color:var(--fg-dim);letter-spacing:.14em;text-transform:uppercase}
.conn-status{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--green);font-weight:600;letter-spacing:.04em}
.conn-dot{width:8px;height:8px;border-radius:50%;background:var(--green);box-shadow:0 0 0 0 var(--green);animation:pulse 1.6s infinite}
@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(45,212,111,.6)}70%{box-shadow:0 0 0 8px transparent}100%{box-shadow:0 0 0 0 transparent}}
.conn-loc{font-size:11px;color:var(--fg-dim);font-variant-numeric:tabular-nums}
nav.tabs{flex:1;padding:14px 8px;display:flex;flex-direction:column;gap:1px;overflow-y:auto}
.tab{display:flex;align-items:center;gap:12px;padding:10px 12px;border:0;background:transparent;color:var(--fg-dim);font:inherit;text-align:left;border-left:2px solid transparent;letter-spacing:.02em;font-size:12px;text-transform:uppercase;letter-spacing:.08em}
.tab .num{font-size:9px;color:var(--fg-mute);width:18px}
.tab .ic{width:14px;height:14px;flex-shrink:0;stroke:currentColor;fill:none;stroke-width:1.4}
.tab .lbl{flex:1}
.tab .badge{font-size:9px;background:var(--red);color:#fff;padding:1px 6px;letter-spacing:.1em}
.tab:hover{color:var(--fg);background:var(--bg-2)}
.tab.active{color:var(--fg);background:var(--bg-2);border-left-color:var(--red)}
.tab.active .num{color:var(--red)}
.side-foot{padding:16px;border-top:1px solid var(--line);font-size:10px;color:var(--fg-dim);letter-spacing:.12em;display:flex;flex-direction:column;gap:8px}
.user{display:flex;align-items:center;gap:10px;text-transform:none;letter-spacing:0;font-size:12px;color:var(--fg)}
.avatar{width:28px;height:28px;border:1px solid var(--line-2);display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;color:var(--red);flex-shrink:0}
.user-meta{flex:1;min-width:0}
.user-name{font-weight:600;font-size:12px}
.user-plan{font-size:10px;color:var(--fg-dim);letter-spacing:.14em;text-transform:uppercase}
.icon-btn{width:24px;height:24px;border:1px solid var(--line-2);background:transparent;color:var(--fg-dim);display:inline-flex;align-items:center;justify-content:center;font-size:11px}
.icon-btn:hover{color:var(--fg);border-color:var(--fg-dim)}
/* main */
main{display:flex;flex-direction:column;min-width:0}
.topbar{display:flex;align-items:center;justify-content:space-between;padding:14px 28px;border-bottom:1px solid var(--line);position:sticky;top:0;background:color-mix(in oklab, var(--bg) 88%, transparent);backdrop-filter:blur(12px);z-index:10}
.crumbs{display:flex;align-items:center;gap:10px;font-size:11px;color:var(--fg-dim);letter-spacing:.14em;text-transform:uppercase}
.crumbs b{color:var(--fg);font-weight:600}
.crumbs .sep{color:var(--fg-mute)}
.top-actions{display:flex;align-items:center;gap:10px}
.search{display:flex;align-items:center;gap:8px;padding:6px 10px;border:1px solid var(--line-2);background:var(--bg-2);font-size:11px;color:var(--fg-dim);min-width:240px}
.search input{background:transparent;border:0;color:var(--fg);font:inherit;outline:none;flex:1;font-size:12px}
.kbd{font-size:9px;border:1px solid var(--line-2);padding:1px 5px;color:var(--fg-dim);letter-spacing:.06em}
.btn{display:inline-flex;align-items:center;gap:8px;height:32px;padding:0 14px;border:1px solid var(--fg);background:var(--fg);color:var(--bg);font-family:var(--mono);font-weight:600;font-size:11px;letter-spacing:.06em;text-transform:uppercase;border-radius:0;transition:transform .12s, box-shadow .18s}
.btn:hover{transform:translate(-1px,-1px);box-shadow:3px 3px 0 var(--red)}
.btn-ghost{background:transparent;color:var(--fg)}
.btn-ghost:hover{background:var(--fg);color:var(--bg);box-shadow:none;transform:none}
.btn-red{background:var(--red);border-color:var(--red);color:#fff}
.btn-red:hover{box-shadow:3px 3px 0 var(--fg)}
.btn-sm{height:26px;padding:0 10px;font-size:10px}
/* content area */
.content{padding:28px;display:flex;flex-direction:column;gap:24px;min-height:0}
/* page header */
.page-h{display:flex;justify-content:space-between;align-items:flex-end;gap:24px}
.eyebrow{font-size:10px;color:var(--red);letter-spacing:.22em;text-transform:uppercase;display:inline-flex;align-items:center;gap:8px}
.eyebrow::before{content:"";width:18px;height:1px;background:var(--red)}
.page-title{font-size:28px;line-height:1;letter-spacing:-.02em;font-weight:700;margin:8px 0 0;text-transform:uppercase}
.page-meta{font-size:11px;color:var(--fg-dim);letter-spacing:.14em;text-transform:uppercase;text-align:right}
/* grid panels */
.grid{display:grid;gap:16px}
.grid-3{grid-template-columns:1.4fr 1fr 1fr}
.grid-2{grid-template-columns:2fr 1fr}
.grid-4{grid-template-columns:repeat(4,1fr)}
.panel{border:1px solid var(--line);background:var(--bg);display:flex;flex-direction:column;min-height:0}
.panel-h{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid var(--line);font-size:10px;color:var(--fg-dim);letter-spacing:.16em;text-transform:uppercase}
.panel-h b{color:var(--fg);font-weight:600}
.panel-h .red{color:var(--red)}
.panel-b{padding:18px 16px;flex:1}
/* connect block */
.connect{padding:24px;display:grid;grid-template-columns:auto 1fr;gap:24px;align-items:center}
.conn-btn{width:120px;height:120px;border:2px solid var(--red);background:var(--red);color:#fff;font-family:var(--mono);font-weight:800;font-size:13px;letter-spacing:.12em;text-transform:uppercase;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;border-radius:0;position:relative;transition:all .15s}
.conn-btn::after{content:"";position:absolute;inset:-6px;border:1px solid var(--red);opacity:.4;pointer-events:none}
.conn-btn .ico{font-size:28px;line-height:1}
.conn-btn:hover{box-shadow:6px 6px 0 var(--fg)}
.conn-btn[data-on="0"]{background:transparent;color:var(--fg);border-color:var(--fg-dim)}
.conn-btn[data-on="0"]::after{border-color:var(--fg-dim)}
.conn-info{display:flex;flex-direction:column;gap:8px}
.conn-info .big{font-size:24px;font-weight:700;letter-spacing:-.01em;line-height:1;text-transform:uppercase}
.conn-info .big .accent{color:var(--green)}
.conn-info .row{display:flex;gap:24px;font-size:11px;color:var(--fg-dim);letter-spacing:.12em;text-transform:uppercase;margin-top:4px}
.conn-info .row .v{color:var(--fg);font-weight:600;margin-left:6px;font-variant-numeric:tabular-nums}
.conn-meta-line{font-size:10px;color:var(--fg-mute);letter-spacing:.16em;font-family:var(--mono)}
/* stats row */
.stat{padding:18px 16px}
.stat-k{font-size:10px;color:var(--fg-dim);letter-spacing:.14em;text-transform:uppercase}
.stat-v{font-size:28px;font-weight:700;letter-spacing:-.01em;margin-top:6px;font-variant-numeric:tabular-nums;line-height:1}
.stat-v .u{color:var(--red);font-size:14px;margin-left:4px}
.stat-d{font-size:10px;color:var(--green);margin-top:6px;letter-spacing:.1em}
.stat-d.down{color:var(--red)}
/* charts */
.chart{height:180px;width:100%}
.chart-foot{display:flex;justify-content:space-between;font-size:10px;color:var(--fg-dim);letter-spacing:.12em;padding:10px 16px;border-top:1px solid var(--line)}
/* server list */
.srv-list{display:flex;flex-direction:column}
.srv{display:grid;grid-template-columns:auto 1fr auto auto auto;gap:14px;align-items:center;padding:11px 16px;border-bottom:1px solid var(--line);font-size:12px}
.srv:last-child{border-bottom:0}
.srv:hover{background:var(--bg-2)}
.srv.active{background:var(--bg-2);border-left:2px solid var(--red);padding-left:14px}
.flag{width:22px;height:16px;border:1px solid var(--line-2);font-size:9px;display:inline-flex;align-items:center;justify-content:center;font-weight:700;letter-spacing:.04em;color:var(--fg-dim)}
.srv .name{font-weight:600}
.srv .city{color:var(--fg-dim);font-size:11px;margin-left:8px}
.srv .ping{font-variant-numeric:tabular-nums;color:var(--fg-dim);font-size:11px;letter-spacing:.06em}
.srv .ping b{color:var(--fg)}
.load-bar{width:60px;height:4px;background:var(--line);position:relative}
.load-bar i{position:absolute;left:0;top:0;height:100%;background:var(--green)}
.load-bar i.med{background:var(--yellow)}
.load-bar i.high{background:var(--red)}
.srv .pick{font-size:10px;letter-spacing:.12em;color:var(--fg-dim);text-transform:uppercase}
.srv:hover .pick{color:var(--red)}
.srv.active .pick{color:var(--red)}
/* feed */
.feed{font-size:11px;font-family:var(--mono);padding:8px 16px;max-height:240px;overflow-y:auto}
.feed-line{display:flex;gap:10px;padding:4px 0;color:var(--fg-dim);font-variant-numeric:tabular-nums}
.feed-line .ts{color:var(--fg-mute);flex-shrink:0}
.feed-line .lvl{flex-shrink:0;width:48px;font-weight:700}
.feed-line .lvl.ok{color:var(--green)}
.feed-line .lvl.warn{color:var(--yellow)}
.feed-line .lvl.err{color:var(--red)}
.feed-line .msg{color:var(--fg)}
/* devices grid */
.dev{padding:16px;border:1px solid var(--line);background:var(--bg);display:flex;flex-direction:column;gap:10px}
.dev .top{display:flex;justify-content:space-between;align-items:flex-start}
.dev-name{font-size:13px;font-weight:600}
.dev-id{font-size:10px;color:var(--fg-dim);letter-spacing:.14em}
.dev .body{font-size:11px;color:var(--fg-dim);display:flex;flex-direction:column;gap:4px}
.dev .body b{color:var(--fg);font-weight:500}
.dev .foot{display:flex;justify-content:space-between;align-items:center;border-top:1px dashed var(--line-2);padding-top:10px}
.dev .pill{font-size:9px;letter-spacing:.14em;text-transform:uppercase;padding:3px 6px;border:1px solid var(--green);color:var(--green)}
.dev .pill.off{border-color:var(--fg-mute);color:var(--fg-dim)}
/* invoice list */
.inv{display:grid;grid-template-columns:120px 1fr auto auto auto;gap:16px;padding:11px 16px;border-bottom:1px solid var(--line);font-size:12px;align-items:center}
.inv:last-child{border-bottom:0}
.inv .date{font-variant-numeric:tabular-nums;color:var(--fg-dim);letter-spacing:.06em}
.inv .label{font-weight:500}
.inv .amount{font-variant-numeric:tabular-nums;font-weight:700}
.inv .pill{font-size:9px;letter-spacing:.14em;text-transform:uppercase;padding:3px 6px;border:1px solid var(--green);color:var(--green)}
.inv .pill.fail{border-color:var(--red);color:var(--red)}
.inv .dl{font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--fg-dim)}
.inv .dl:hover{color:var(--red)}
/* settings rows */
.opt{display:flex;justify-content:space-between;align-items:center;padding:14px 16px;border-bottom:1px solid var(--line);gap:16px}
.opt:last-child{border-bottom:0}
.opt .opt-l{display:flex;flex-direction:column;gap:2px}
.opt .opt-name{font-size:13px;font-weight:500}
.opt .opt-d{font-size:11px;color:var(--fg-dim);max-width:60ch}
.toggle{position:relative;width:36px;height:20px;border:0;border-radius:0;background:var(--line);transition:background .15s;padding:0;border:1px solid var(--line-2)}
.toggle[data-on="1"]{background:var(--red);border-color:var(--red)}
.toggle i{position:absolute;top:1px;left:1px;width:16px;height:16px;background:#fff;transition:transform .15s}
.toggle[data-on="1"] i{transform:translateX(16px)}
.seg{display:inline-flex;border:1px solid var(--line-2)}
.seg button{appearance:none;background:transparent;border:0;color:var(--fg-dim);padding:6px 10px;font:inherit;font-size:10px;letter-spacing:.14em;text-transform:uppercase;border-right:1px solid var(--line-2)}
.seg button:last-child{border-right:0}
.seg button.on{background:var(--fg);color:var(--bg)}
/* support */
.ticket{padding:14px 16px;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;gap:16px;align-items:center}
.ticket:last-child{border-bottom:0}
.ticket .meta{font-size:10px;color:var(--fg-dim);letter-spacing:.14em;text-transform:uppercase}
.ticket .subj{font-size:13px;margin-top:4px}
.ticket .right{text-align:right;font-size:11px;color:var(--fg-dim)}
/* security */
.sec-row{display:flex;align-items:center;gap:14px;padding:14px 16px;border-bottom:1px solid var(--line)}
.sec-row:last-child{border-bottom:0}
.sec-ico{width:32px;height:32px;border:1px solid var(--line-2);display:flex;align-items:center;justify-content:center;color:var(--green);flex-shrink:0}
.sec-ico.warn{color:var(--yellow);border-color:var(--yellow)}
.sec-ico svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:1.5}
.sec-row .body{flex:1}
.sec-row .body b{display:block;font-size:13px;margin-bottom:2px}
.sec-row .body span{font-size:11px;color:var(--fg-dim)}
/* referral */
.ref-card{padding:24px 20px;border:1px solid var(--red);background:linear-gradient(180deg, color-mix(in oklab, var(--red) 12%, transparent) 0%, transparent 100%)}
.ref-num{font-size:42px;font-weight:800;letter-spacing:-.02em;line-height:1}
.ref-num .u{color:var(--red);font-size:18px;margin-left:4px}
.ref-link{display:flex;align-items:center;border:1px solid var(--line-2);background:var(--bg-2);padding:10px 14px;margin-top:18px;font-size:12px;justify-content:space-between;gap:12px}
.ref-link code{color:var(--fg);font-family:var(--mono);font-size:12px;letter-spacing:0}
.empty{padding:40px 16px;text-align:center;color:var(--fg-dim);font-size:12px}
.empty-big{font-size:18px;color:var(--fg);margin-bottom:8px;letter-spacing:.04em;text-transform:uppercase}
@media (max-width: 1100px){
.grid-3{grid-template-columns:1fr 1fr}
.grid-4{grid-template-columns:repeat(2,1fr)}
}
@media (max-width: 760px){
.app{grid-template-columns:1fr}
aside{position:relative;height:auto}
.grid-3,.grid-4,.grid-2{grid-template-columns:1fr}
}
</style>
</head>
<body>
<div id="root"></div>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script type="text/babel" data-presets="env,react">
const { useState, useEffect, useRef, useMemo } = React;
// ───────────────────── Icons ─────────────────────
const Ico = {
prof: <svg className="ic" viewBox="0 0 16 16"><circle cx="8" cy="5.5" r="2.6"/><path d="M2.5 14c.6-2.6 2.9-4.2 5.5-4.2S13 11.4 13.5 14"/></svg>,
bill: <svg className="ic" viewBox="0 0 16 16"><rect x="2" y="3" width="12" height="10"/><path d="M2 6h12"/><path d="M5 10h2"/></svg>,
ref: <svg className="ic" viewBox="0 0 16 16"><circle cx="5" cy="5" r="2"/><circle cx="11" cy="11" r="2"/><path d="M5 7v2a2 2 0 0 0 2 2h2"/></svg>,
gift: <svg className="ic" viewBox="0 0 16 16"><rect x="2" y="6" width="12" height="8"/><path d="M2 9h12M8 6v8"/><path d="M5 6c0-1.5 1-2.5 2-2.5S8 4 8 6c0-2 0-2.5 1-2.5S11 4.5 11 6"/></svg>,
bell: <svg className="ic" viewBox="0 0 16 16"><path d="M4 11V7a4 4 0 0 1 8 0v4l1 1.5H3z"/><path d="M6.5 13.5a1.5 1.5 0 0 0 3 0"/></svg>,
};
// ───────────────────── Tabs definition ─────────────────────
const TABS = [
{ id: 'prof', num: '01', label: 'Профиль', icon: Ico.prof },
{ id: 'bill', num: '02', label: 'Подписки', icon: Ico.bill },
{ id: 'ref', num: '03', label: 'Партнёрка', icon: Ico.ref },
{ id: 'gift', num: '04', label: 'Подарки', icon: Ico.gift, badge: '2' },
{ id: 'note', num: '05', label: 'Уведомления', icon: Ico.bell, badge: '5' },
];
// ───────────────────── Server data ─────────────────────
const SERVERS = [
{ code:'NL', city:'Amsterdam', name:'AMS-03', ping:14, load:'low', loadV:22, hops:1 },
{ code:'DE', city:'Frankfurt', name:'FRA-07', ping:18, load:'low', loadV:31 },
{ code:'CH', city:'Zürich', name:'ZRH-02', ping:24, load:'low', loadV:18 },
{ code:'SE', city:'Stockholm', name:'STO-01', ping:32, load:'med', loadV:54 },
{ code:'IS', city:'Reykjavík', name:'KEF-01', ping:48, load:'med', loadV:62 },
{ code:'US', city:'New York', name:'NYC-09', ping:74, load:'low', loadV:38 },
{ code:'US', city:'Los Angeles', name:'LAX-04', ping:128, load:'high', loadV:84 },
{ code:'JP', city:'Tokyo', name:'TYO-02', ping:172, load:'med', loadV:48 },
{ code:'SG', city:'Singapore', name:'SIN-03', ping:188, load:'low', loadV:24 },
];
// ───────────────────── Sparkline ─────────────────────
function Sparkline({ data, color = 'var(--red)', fill = true, height = 180 }){
const ref = useRef(null);
useEffect(() => {
const c = ref.current; if (!c) return;
const ctx = c.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const w = c.clientWidth, h = c.clientHeight;
c.width = w*dpr; c.height = h*dpr; ctx.setTransform(dpr,0,0,dpr,0,0);
ctx.clearRect(0,0,w,h);
// grid
ctx.strokeStyle = 'rgba(245,245,245,0.05)'; ctx.lineWidth = 1; ctx.beginPath();
for (let i=1;i<5;i++){ const y = (h/5)*i; ctx.moveTo(0,y); ctx.lineTo(w,y); }
ctx.stroke();
// data
const max = Math.max(...data) || 1;
const min = Math.min(...data);
const norm = data.map(v => (v - min) / (max - min || 1));
ctx.lineWidth = 1.5;
ctx.strokeStyle = color;
if (fill){
const grad = ctx.createLinearGradient(0,0,0,h);
grad.addColorStop(0, color === 'var(--red)' ? 'rgba(255,45,45,0.35)' : 'rgba(45,212,111,0.35)');
grad.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.moveTo(0, h - norm[0]*(h-20) - 8);
for (let i=1;i<norm.length;i++){
const x = (i/(norm.length-1))*w;
const y = h - norm[i]*(h-20) - 8;
ctx.lineTo(x,y);
}
ctx.lineTo(w,h); ctx.lineTo(0,h); ctx.closePath(); ctx.fill();
}
ctx.beginPath();
for (let i=0;i<norm.length;i++){
const x = (i/(norm.length-1))*w;
const y = h - norm[i]*(h-20) - 8;
i===0 ? ctx.moveTo(x,y) : ctx.lineTo(x,y);
}
ctx.stroke();
}, [data, color, fill]);
return <canvas ref={ref} className="chart" style={{height}}/>;
}
// ───────────────────── Sidebar ─────────────────────
function Sidebar({ active, setActive }){
return (
<aside>
<div className="brand">
<span className="brand-mark"></span>
<span className="brand-name">SOLONET</span>
<span className="brand-ver">v 4.2.0</span>
</div>
<div className="conn-card">
<div className="conn-row"><span>// TUNNEL</span><span>WG-01</span></div>
<div className="conn-status"><span className="conn-dot"></span> CONNECTED</div>
<div className="conn-loc">NL · AMS-03 · 14 ms</div>
</div>
<nav className="tabs">
{TABS.map(t => (
<button key={t.id} className={`tab ${active===t.id?'active':''}`} onClick={() => setActive(t.id)}>
<span className="num">{t.num}</span>
{t.icon}
<span className="lbl">{t.label}</span>
{t.badge && <span className="badge">{t.badge}</span>}
</button>
))}
</nav>
<div className="side-foot">
<div className="user">
<div className="avatar">SK</div>
<div className="user-meta">
<div className="user-name">solo_kirill</div>
<div className="user-plan">PRIME · 247 дн</div>
</div>
<button className="icon-btn" title="Выйти"></button>
</div>
</div>
</aside>
);
}
// ───────────────────── Topbar ─────────────────────
function Topbar({ active }){
const tab = TABS.find(t => t.id === active);
return (
<div className="topbar">
<div className="crumbs">
<span>Кабинет</span><span className="sep">/</span>
<b>{tab.label}</b>
<span className="sep">·</span>
<span>NODE-AMS-03</span>
</div>
<div className="top-actions">
<div className="search">
<span></span>
<input placeholder="Поиск серверов, устройств…" />
<span className="kbd"> K</span>
</div>
<button className="btn btn-sm btn-ghost">Помощь</button>
<button className="btn btn-sm btn-red">Купить трафик</button>
</div>
</div>
);
}
// ───────────────────── Page: Profile ─────────────────────
function ProfilePage(){
const [twofa, setTwofa] = useState(false);
const [news, setNews] = useState(true);
return (
<>
<div className="page-h">
<div>
<span className="eyebrow">/// Личные данные</span>
<h1 className="page-title">Профиль</h1>
</div>
<div className="page-meta">ID · SK-9F3A2C</div>
</div>
<div className="grid grid-2">
<div className="panel">
<div className="panel-h"><b>// Учётная запись</b><span>аккаунт</span></div>
<div className="panel-b" style={{display:'flex',flexDirection:'column',gap:14}}>
<div style={{display:'flex',gap:14,alignItems:'center'}}>
<div className="avatar" style={{width:56,height:56,fontSize:18}}>SK</div>
<div>
<div style={{fontSize:16,fontWeight:600}}>solo_kirill</div>
<div style={{fontSize:11,color:'var(--fg-dim)',letterSpacing:'.12em',textTransform:'uppercase',marginTop:4}}>с 04 апр 2024 · 763 дня</div>
</div>
<button className="btn btn-sm btn-ghost" style={{marginLeft:'auto'}}>Сменить</button>
</div>
<div style={{height:1,background:'var(--line)'}}></div>
<Field k="E-mail" v="kirill@solonet.io" verified />
<Field k="Телефон" v="+7 ··· ··· 47-12" />
<Field k="Telegram" v="@solo_kirill" verified />
<Field k="Язык" v="Русский" />
</div>
</div>
<div className="panel">
<div className="panel-h"><b>// Безопасность</b><span>защита</span></div>
<div className="opt">
<div className="opt-l"><div className="opt-name">Двухфакторная (2FA)</div><div className="opt-d">Подтверждение по TOTP-коду из приложения. Рекомендуется.</div></div>
<button className="toggle" data-on={twofa?'1':'0'} onClick={()=>setTwofa(!twofa)}><i/></button>
</div>
<div className="opt">
<div className="opt-l"><div className="opt-name">Письма с новостями</div><div className="opt-d">Раз в месяц. Никакого спама только важные обновления.</div></div>
<button className="toggle" data-on={news?'1':'0'} onClick={()=>setNews(!news)}><i/></button>
</div>
<div className="opt">
<div className="opt-l"><div className="opt-name">Сменить пароль</div><div className="opt-d">Последнее изменение 47 дней назад.</div></div>
<button className="btn btn-sm btn-ghost">Изменить</button>
</div>
<div className="opt">
<div className="opt-l"><div className="opt-name" style={{color:'var(--red)'}}>Удалить аккаунт</div><div className="opt-d">Все данные стираются с RAM-серверов в течение 24 часов.</div></div>
<button className="btn btn-sm btn-ghost" style={{borderColor:'var(--red)',color:'var(--red)'}}>Удалить</button>
</div>
</div>
</div>
<div className="panel">
<div className="panel-h"><b>// История входов</b><span>последние 5</span></div>
<div className="srv-list">
{[
['MBP-14 · macOS','198.51.100.91 · NL','сейчас'],
['iPhone 15 Pro','198.51.100.42 · NL','12 мин назад'],
['Web · Firefox','83.220.171.4 · RU','3 ч назад'],
['Router OpenWrt','198.51.100.12 · DE','вчера'],
['ThinkPad X1','—','5 дней назад'],
].map(([n,ip,t],i)=>(
<div className="srv" key={i}>
<span className="flag"></span>
<span><span className="name">{n}</span></span>
<span className="ping">{ip}</span>
<span></span>
<span className="pick">{t}</span>
</div>
))}
</div>
</div>
</>
);
}
function Field({k,v,verified}){
return (
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',gap:12,padding:'8px 0',borderBottom:'1px dashed var(--line)'}}>
<span style={{fontSize:11,color:'var(--fg-dim)',letterSpacing:'.12em',textTransform:'uppercase'}}>{k}</span>
<span style={{display:'flex',alignItems:'center',gap:8}}>
<b style={{fontSize:13,fontWeight:600}}>{v}</b>
{verified && <span style={{fontSize:9,letterSpacing:'.14em',color:'var(--green)',border:'1px solid var(--green)',padding:'2px 5px'}}>VERIFIED</span>}
</span>
</div>
);
}
// ───────────────────── Page: Subscriptions ─────────────────────
function SubsPage(){
const invoices = [
{ date:'2026-04-12', label:'PRIME · 12 мес', amount:'4 788 ₽', status:'PAID' },
{ date:'2025-04-12', label:'PRIME · 12 мес', amount:'4 788 ₽', status:'PAID' },
{ date:'2024-10-04', label:'CORE · 6 мес', amount:'1 494 ₽', status:'PAID' },
{ date:'2024-04-04', label:'PROBE · 1 мес', amount:'99 ₽', status:'FAIL' },
];
return (
<>
<div className="page-h">
<div>
<span className="eyebrow">/// Тарифы и платежи</span>
<h1 className="page-title">Подписки</h1>
</div>
<div className="page-meta">PRIME · ДО 12 АПР 2027</div>
</div>
<div className="grid grid-4">
<Stat k="До продления" v="347" u="дн" d="автопродление активно" />
<Stat k="Списание" v="4 788" u="₽" d="12 апр 2027" />
<Stat k="Сэкономлено" v="3 624" u="₽" d="скидка за год · 39%" />
<Stat k="Бонусных дней" v="+210" d="7 рефералов" />
</div>
<div className="grid grid-2">
<div className="panel">
<div className="panel-h"><b>// Текущий тариф</b><span className="red">PRIME</span></div>
<div className="panel-b" style={{display:'flex',flexDirection:'column',gap:18}}>
<div style={{fontSize:48,fontWeight:800,letterSpacing:'-.02em',lineHeight:1}}>399 <span style={{fontSize:18,color:'var(--fg-dim)'}}> / мес</span></div>
<div style={{fontSize:11,color:'var(--fg-dim)',letterSpacing:'.14em',textTransform:'uppercase'}}>Списание 4 788 12 АПР 2027</div>
<div style={{height:1,background:'var(--line)'}}></div>
<ul style={{listStyle:'none',padding:0,margin:0,fontSize:12,display:'flex',flexDirection:'column',gap:8,color:'var(--fg-dim)'}}>
<li style={{display:'flex',justifyContent:'space-between'}}><span>Серверы</span><b style={{color:'var(--fg)'}}>412</b></li>
<li style={{display:'flex',justifyContent:'space-between'}}><span>Устройств</span><b style={{color:'var(--fg)'}}>10</b></li>
<li style={{display:'flex',justifyContent:'space-between'}}><span>Multi-hop</span><b style={{color:'var(--fg)'}}>2 узла</b></li>
<li style={{display:'flex',justifyContent:'space-between'}}><span>Поддержка</span><b style={{color:'var(--fg)'}}>Live · 24/7</b></li>
</ul>
<div style={{display:'flex',gap:10,marginTop:'auto',paddingTop:8}}>
<button className="btn btn-red">Перейти на SHADOW </button>
<button className="btn btn-sm btn-ghost">Отменить</button>
</div>
</div>
</div>
<div className="panel">
<div className="panel-h"><b>// Метод оплаты</b><span>основной</span></div>
<div className="panel-b" style={{display:'flex',flexDirection:'column',gap:14}}>
<div style={{border:'1px solid var(--line-2)',padding:16,display:'flex',justifyContent:'space-between',alignItems:'center'}}>
<div>
<div style={{fontSize:13,fontWeight:600}}> 4 829</div>
<div style={{fontSize:11,color:'var(--fg-dim)',marginTop:4,letterSpacing:'.1em'}}>VISA · ИСТЕКАЕТ 11/28</div>
</div>
<button className="btn btn-sm btn-ghost">Сменить</button>
</div>
<div style={{fontSize:11,color:'var(--fg-dim)',letterSpacing:'.12em',textTransform:'uppercase'}}>// Альтернативные методы</div>
<div style={{display:'flex',gap:8,flexWrap:'wrap'}}>
<span style={{padding:'6px 10px',border:'1px solid var(--line-2)',fontSize:11,letterSpacing:'.08em'}}>SBP</span>
<span style={{padding:'6px 10px',border:'1px solid var(--line-2)',fontSize:11,letterSpacing:'.08em'}}>BTC</span>
<span style={{padding:'6px 10px',border:'1px solid var(--line-2)',fontSize:11,letterSpacing:'.08em'}}>USDT</span>
<span style={{padding:'6px 10px',border:'1px solid var(--line-2)',fontSize:11,letterSpacing:'.08em'}}>MONERO</span>
</div>
</div>
</div>
</div>
<div className="panel">
<div className="panel-h"><b>// История платежей</b><span>{invoices.length} записей</span></div>
<div>
{invoices.map((inv,i) => (
<div className="inv" key={i}>
<span className="date">{inv.date}</span>
<span className="label">{inv.label}</span>
<span className="amount">{inv.amount}</span>
<span className={`pill ${inv.status==='FAIL'?'fail':''}`}>{inv.status}</span>
<a className="dl" href="#"> PDF</a>
</div>
))}
</div>
</div>
</>
);
}
// ───────────────────── Page: Partner ─────────────────────
function PartnerPage(){
return (
<>
<div className="page-h">
<div>
<span className="eyebrow">/// Приведи одного</span>
<h1 className="page-title">Партнёрка</h1>
</div>
<div className="page-meta">+30 ДНЕЙ + 30% за каждого</div>
</div>
<div className="grid grid-4">
<Stat k="Приведено" v="7" d="+2 за месяц" />
<Stat k="Бонус-дней" v="+210" d="к подписке" />
<Stat k="Кэшбек" v="1 050" u="₽" d="к выводу" />
<Stat k="Конверсия" v="58" u="%" d="переход → оплата" />
</div>
<div className="grid grid-2">
<div className="ref-card">
<div style={{fontSize:11,color:'var(--fg-dim)',letterSpacing:'.16em',textTransform:'uppercase',marginBottom:12}}>// твоя реферальная ссылка</div>
<div className="ref-num">SK<span className="u">-9F3A</span></div>
<div className="ref-link">
<code>solonet.io/r/SK-9F3A</code>
<button className="btn btn-sm">Копировать</button>
</div>
<div style={{display:'flex',gap:10,marginTop:18}}>
<button className="btn btn-sm btn-ghost">Поделиться</button>
<button className="btn btn-sm btn-ghost">QR-код</button>
<button className="btn btn-sm btn-ghost">UTM</button>
</div>
</div>
<div className="panel">
<div className="panel-h"><b>// Условия программы</b><span>tier · BRONZE</span></div>
<div className="panel-b" style={{display:'flex',flexDirection:'column',gap:12,fontSize:12,color:'var(--fg-dim)'}}>
<div style={{display:'flex',justifyContent:'space-between'}}><span>Друг получает</span><b style={{color:'var(--fg)'}}>30% на 1-й платёж</b></div>
<div style={{display:'flex',justifyContent:'space-between'}}><span>Ты получаешь</span><b style={{color:'var(--fg)'}}>+30 дней + 30%</b></div>
<div style={{display:'flex',justifyContent:'space-between'}}><span>Активация</span><b style={{color:'var(--fg)'}}>после оплаты</b></div>
<div style={{display:'flex',justifyContent:'space-between'}}><span>Срок жизни ссылки</span><b style={{color:'var(--fg)'}}>30 дней</b></div>
<div style={{height:1,background:'var(--line)',margin:'4px 0'}}></div>
<div style={{fontSize:10,letterSpacing:'.16em',textTransform:'uppercase',color:'var(--fg-dim)'}}>// до SILVER · 13 рефералов</div>
<div style={{height:6,background:'var(--line)',position:'relative'}}><div style={{position:'absolute',left:0,top:0,bottom:0,width:'35%',background:'var(--red)'}}/></div>
</div>
</div>
</div>
<div className="panel">
<div className="panel-h"><b>// Приведённые друзья</b><span>7 человек</span></div>
<div className="srv-list">
{[
['solo_anya','PRIME · 12 мес','+30 дн','2 дня назад'],
['mr_quiet','CORE · 6 мес','+30 дн','1 нед назад'],
['root@ghost','SHADOW · 12 мес','+30 дн','2 нед назад'],
['ave','PROBE','—','1 мес назад'],
['nox_4','CORE · 6 мес','+30 дн','1 мес назад'],
['v.lonely','PRIME · 12 мес','+30 дн','2 мес назад'],
['static_id','PRIME · 12 мес','+30 дн','3 мес назад'],
].map(([n,p,b,t],i)=>(
<div className="srv" key={i}>
<span className="flag">@</span>
<span><span className="name">{n}</span><span className="city">· {p}</span></span>
<span className="ping" style={{color:'var(--green)'}}>{b}</span>
<span></span>
<span className="pick">{t}</span>
</div>
))}
</div>
</div>
</>
);
}
// ───────────────────── Page: Gifts ─────────────────────
function GiftsPage(){
return (
<>
<div className="page-h">
<div>
<span className="eyebrow">/// Промо и бонусы</span>
<h1 className="page-title">Подарки</h1>
</div>
<div className="page-meta">2 АКТИВНЫХ · 1 НОВЫЙ</div>
</div>
<div className="grid grid-3">
{[
{ tag:'NEW', t:'+30 дней SHADOW', d:'Активируй апгрейд за 7 дней — получи +30 дней триала премиум-уровня.', exp:'до 06 МАЯ', cta:'Активировать', red:true },
{ tag:'ACTIVE', t:'50% на год CORE', d:'Промо для друга. Один раз на новый аккаунт.', exp:'до 31 МАЯ', cta:'Скопировать код', code:'SOLO50-FRIEND' },
{ tag:'ACTIVE', t:'+15 дней за 2FA', d:'Включи двухфакторку — получи дополнительные 15 дней к подписке.', exp:'бессрочно', cta:'Включить 2FA' },
].map((g,i)=>(
<div className="panel" key={i} style={g.red?{borderColor:'var(--red)'}:{}}>
<div className="panel-h"><b className={g.red?'red':''}>// {g.tag}</b><span>{g.exp}</span></div>
<div className="panel-b" style={{display:'flex',flexDirection:'column',gap:12,minHeight:160}}>
<div style={{fontSize:22,fontWeight:700,letterSpacing:'-.01em',lineHeight:1.05,textTransform:'uppercase'}}>{g.t}</div>
<div style={{fontSize:12,color:'var(--fg-dim)'}}>{g.d}</div>
{g.code && <div style={{padding:'8px 10px',border:'1px dashed var(--line-2)',fontSize:13,fontWeight:600,letterSpacing:'.08em',color:'var(--red)'}}>{g.code}</div>}
<button className={`btn ${g.red?'btn-red':'btn-ghost'}`} style={{marginTop:'auto'}}>{g.cta} </button>
</div>
</div>
))}
</div>
<div className="grid grid-2">
<div className="panel">
<div className="panel-h"><b>// Промо-код</b><span>введи и активируй</span></div>
<div className="panel-b" style={{display:'flex',gap:10,alignItems:'stretch'}}>
<input placeholder="PROMO-XXXX-XXXX" style={{flex:1,background:'var(--bg-2)',border:'1px solid var(--line-2)',color:'var(--fg)',font:'inherit',padding:'10px 14px',outline:'none',letterSpacing:'.08em'}}/>
<button className="btn btn-red">Активировать</button>
</div>
</div>
<div className="panel">
<div className="panel-h"><b>// История подарков</b><span>5 активаций</span></div>
<div className="srv-list">
{[
['+30 дней триала PRIME','SOLO-FRIEND-9F3A','12 апр 2026'],
['Кэшбек 1 050 ₽','REF-PROGRAM','01 апр 2026'],
['+15 дней за 2FA','—','—'],
['50% на CORE','BLACKFRI-2025','24 ноя 2025'],
].map(([t,c,d],i)=>(
<div className="srv" key={i}>
<span className="flag" style={{color:'var(--red)'}}></span>
<span><span className="name">{t}</span></span>
<span className="ping">{c}</span>
<span></span>
<span className="pick" style={{color:'var(--fg-dim)'}}>{d}</span>
</div>
))}
</div>
</div>
</div>
</>
);
}
// ───────────────────── Page: Notifications ─────────────────────
function NotificationsPage(){
const [filter, setFilter] = useState('all');
const items = [
{ lvl:'WARN', cls:'warn', t:'Замечена попытка входа из другой страны', d:'IP 213.180.42.8 · Германия · браузер Chrome. Если это не вы — смените пароль.', ts:'14:08 · сегодня', unread:true },
{ lvl:'INFO', cls:'', t:'Новый сервер в сети — Лиссабон (LIS-01)', d:'Доступен в тарифах PRIME и SHADOW. Латентность из Москвы — 64 мс.', ts:'10 ч назад', unread:true },
{ lvl:'OK', cls:'ok', t:'Платёж 4 788 ₽ прошёл успешно', d:'Подписка PRIME продлена до 12 апреля 2027.', ts:'12 апр', unread:true },
{ lvl:'INFO', cls:'', t:'Друг solo_anya активировал PRIME · 12 мес', d:'+30 бонусных дней начислено к твоей подписке.', ts:'10 апр', unread:true },
{ lvl:'WARN', cls:'warn', t:'2FA не включён', d:'Рекомендуем настроить — это даст +15 дней подписки в подарок.', ts:'05 апр', unread:true },
{ lvl:'OK', cls:'ok', t:'Утечка данных не обнаружена', d:'Авто-аудит DNS · IPv6 · WebRTC прошёл успешно.', ts:'02 апр' },
{ lvl:'INFO', cls:'', t:'Обновление клиента 4.2.0', d:'Поддержка obfuscated протокола, починка автостарта на macOS Sonoma.', ts:'28 мар' },
{ lvl:'INFO', cls:'', t:'Сменили дизайн кабинета', d:'Добавили вкладки Подарки и Партнёрку — загляни.', ts:'15 мар' },
];
const filtered = filter==='unread' ? items.filter(i=>i.unread) : filter==='warn' ? items.filter(i=>i.lvl==='WARN') : items;
return (
<>
<div className="page-h">
<div>
<span className="eyebrow">/// Системные события</span>
<h1 className="page-title">Уведомления</h1>
</div>
<div className="page-meta">5 НЕПРОЧИТАННЫХ</div>
</div>
<div className="panel">
<div className="panel-h" style={{justifyContent:'space-between'}}>
<b>// Лента событий</b>
<div style={{display:'flex',gap:10,alignItems:'center'}}>
<div className="seg">
{[['all','Все · 8'],['unread','Новые · 5'],['warn','Важные · 2']].map(([k,l]) => (
<button key={k} className={filter===k?'on':''} onClick={()=>setFilter(k)}>{l}</button>
))}
</div>
<button className="btn btn-sm btn-ghost">Пометить как прочитанные</button>
</div>
</div>
<div>
{filtered.map((n,i)=>(
<div key={i} style={{display:'grid',gridTemplateColumns:'auto 60px 1fr auto',gap:14,alignItems:'flex-start',padding:'14px 16px',borderBottom:'1px solid var(--line)',background:n.unread?'var(--bg-2)':'transparent'}}>
<span style={{width:8,height:8,marginTop:6,background:n.unread?'var(--red)':'var(--fg-mute)',display:'inline-block',borderRadius:'50%'}}></span>
<span className={`lvl ${n.cls}`} style={{fontSize:10,fontWeight:700,letterSpacing:'.14em',marginTop:2,color:n.cls==='ok'?'var(--green)':n.cls==='warn'?'var(--yellow)':'var(--fg-dim)'}}>{n.lvl}</span>
<div>
<div style={{fontSize:13,fontWeight:600,marginBottom:3}}>{n.t}</div>
<div style={{fontSize:11,color:'var(--fg-dim)',lineHeight:1.5,maxWidth:'70ch'}}>{n.d}</div>
</div>
<span style={{fontSize:10,color:'var(--fg-dim)',letterSpacing:'.12em',textTransform:'uppercase',whiteSpace:'nowrap',marginTop:2}}>{n.ts}</span>
</div>
))}
</div>
</div>
<div className="panel">
<div className="panel-h"><b>// Каналы доставки</b><span>куда присылать</span></div>
<NotifChannel name="E-mail" v="kirill@solonet.io" on={true}/>
<NotifChannel name="Telegram" v="@solo_kirill" on={true}/>
<NotifChannel name="Push в приложении" v="3 устройства" on={true}/>
<NotifChannel name="SMS" v="+7 ··· ··· 47-12" on={false}/>
</div>
</>
);
}
function NotifChannel({name,v,on:onInit}){
const [on,setOn] = useState(onInit);
return (
<div className="opt">
<div className="opt-l"><div className="opt-name">{name}</div><div className="opt-d">{v}</div></div>
<button className="toggle" data-on={on?'1':'0'} onClick={()=>setOn(!on)}><i/></button>
</div>
);
}
function Stat({ k, v, u, d, down }){
return (
<div className="panel stat">
<div className="stat-k">{k}</div>
<div className="stat-v">{v}{u && <span className="u">{u}</span>}</div>
{d && <div className={`stat-d ${down?'down':''}`}>{down?'↓':'↑'} {d}</div>}
</div>
);
}
// ───────────────────── App ─────────────────────
const PAGES = {
prof: ProfilePage,
bill: SubsPage,
ref: PartnerPage,
gift: GiftsPage,
note: NotificationsPage,
};
function App(){
const [active, setActive] = useState('prof');
const Page = PAGES[active];
return (
<div className="app" data-screen-label={`Cabinet · ${active}`}>
<Sidebar active={active} setActive={setActive}/>
<main>
<Topbar active={active}/>
<div className="content">
<Page />
</div>
</main>
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App/>);
</script>
</body>
</html>
+1154
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -50,6 +50,7 @@ async def _resolve_partner_snapshot(session: AsyncSession, billing_user_id: int)
"partner_percent": default_percent,
"partner_percent_custom": False,
"partner_referred_total": 0,
"partner_referred_paid": 0,
"partner_payout_method": None,
}
try:
@@ -95,6 +96,7 @@ async def _resolve_partner_snapshot(session: AsyncSession, billing_user_id: int)
logger.warning("[Auth] Ошибка сохранения partner_code для billing_user_id={}: {}", billing_user_id, e)
payout_method = str(partner_row[5] or "").strip() or None
referred_total = 0
referred_paid = 0
if tg_id is not None:
try:
referred_total = int(
@@ -108,6 +110,27 @@ async def _resolve_partner_snapshot(session: AsyncSession, billing_user_id: int)
)
except Exception:
referred_total = 0
try:
referred_paid = int(
(
await session.execute(
text(
"SELECT COUNT(DISTINCT pr.joined_tg_id) "
"FROM partners pr "
"WHERE pr.partner_tg_id = :tg_id "
"AND EXISTS ("
" SELECT 1 FROM payments pay "
" WHERE pay.tg_id = pr.joined_tg_id "
" AND lower(pay.status) = 'success'"
")"
),
{"tg_id": int(tg_id)},
)
).scalar()
or 0
)
except Exception:
referred_paid = 0
payload.update({
"partner_enabled": bool(partner_feature_enabled or code or referred_total > 0 or balance > 0),
"partner_code": code,
@@ -115,6 +138,7 @@ async def _resolve_partner_snapshot(session: AsyncSession, billing_user_id: int)
"partner_percent": percent_value,
"partner_percent_custom": percent_custom,
"partner_referred_total": referred_total,
"partner_referred_paid": referred_paid,
"partner_payout_method": payout_method,
})
return payload
+1
View File
@@ -224,6 +224,7 @@ async def auth_summary(
partner_percent=float(partner.get("partner_percent") or 0.0),
partner_percent_custom=bool(partner.get("partner_percent_custom", False)),
partner_referred_total=int(partner.get("partner_referred_total") or 0),
partner_referred_paid=int(partner.get("partner_referred_paid") or 0),
partner_payout_method=partner.get("partner_payout_method"),
unread_notifications=int(unread_notifications),
)
+1
View File
@@ -22,6 +22,7 @@ class AccountSummaryResponse(BaseModel):
partner_percent: float = 0.0
partner_percent_custom: bool = False
partner_referred_total: int = 0
partner_referred_paid: int = 0
partner_payout_method: str | None = None
unread_notifications: int = 0
@@ -46,6 +46,7 @@ PAYMENT_PROVIDER_TITLES: Final[dict[str, str]] = {
"KASSAI_SBP": "KassaAI СБП",
"WATA_RU": "WATA карты РФ / СБП",
"WATA_INT": "WATA международные",
"PARITYPAY_SBP": "ParityPay СБП",
"TRIBUTE": "Tribute",
"HELEKET": "Heleket",
"CRYPTOBOT": "CryptoBot",
+2 -2
View File
@@ -86,7 +86,7 @@ def build_tariffs_arrangement_kb(group_code: str, tariffs: list) -> InlineKeyboa
callback_data=AdminTariffCallback(action=f"quick_move_up|{t.get('id')}|{group_code}").pack(),
),
InlineKeyboardButton(
text=f" {t.get('name')} ", callback_data=AdminTariffCallback(action=f"view|{t.get('id')}").pack()
text=f" #{t.get('id')} · {t.get('name')} ", callback_data=AdminTariffCallback(action=f"view|{t.get('id')}").pack()
),
InlineKeyboardButton(
text="⬇️",
@@ -178,7 +178,7 @@ def build_tariff_list_kb(tariffs: list[dict]) -> InlineKeyboardMarkup:
)
for t in grouped.get(None, []):
title = f"{t['name']}{t['price_rub']}"
title = f"#{t['id']} · {t['name']}{t['price_rub']}"
builder.row(
InlineKeyboardButton(
text=title,
+2 -1
View File
@@ -44,7 +44,8 @@ def render_tariff_card(tariff: Tariff) -> tuple[str, InlineKeyboardMarkup]:
external_squad_text = getattr(tariff, "external_squad", None) or "Не задан"
text = (
f"<b>📄 Тариф: {tariff.name}</b>\n\n"
f"<b>📄 Тариф: {tariff.name}</b>\n"
f"🆔 ID: <code>{tariff.id}</code>\n\n"
f"📁 Группа: <code>{tariff.group_code}</code>\n"
f"📅 Длительность: <b>{tariff.duration_days} дней</b>\n"
f"💰 Стоимость: <b>{tariff.price_rub}₽</b>\n"
+1
View File
@@ -85,6 +85,7 @@ WATA_INT = "🌍 WATA: Международные карты"
KASSAI = "💳 KassaAI"
KASSAI_CARDS = "💳 KassaAI: Карты РФ"
KASSAI_SBP = "🏦 KassaAI: СБП"
PARITYPAY_SBP = "🏦 ParityPay: СБП"
TRIBUTE = "💳 Tribute"
HELEKET = "Heleket Crypto"
+3
View File
@@ -23,6 +23,7 @@ from .freekassa.freekassa_pay import router as freekassa_router
from .gift import router as gift_router
from .heleket import router as heleket_router
from .kassai import router as kassai_router
from .paritypay import router as paritypay_router
from .pay import router as pay_router
from .robokassa import router as robokassa_router
from .stars import router as stars_router
@@ -54,6 +55,8 @@ if PROVIDERS.get("HELEKET", {}).get("enabled"):
router.include_router(heleket_router)
if PROVIDERS.get("WATA_RU", {}).get("enabled") or PROVIDERS.get("WATA_INT", {}).get("enabled"):
router.include_router(wata_router)
if PROVIDERS.get("PARITYPAY_SBP", {}).get("enabled"):
router.include_router(paritypay_router)
router.include_router(tribute_router)
router.include_router(gift_router)
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .handlers import router
+107
View File
@@ -0,0 +1,107 @@
from aiogram import F, Router, types
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_temporary_data
from database.models import User
from handlers.buttons import MAIN_MENU, PAY_2
from handlers.texts import DEFAULT_PAYMENT_MESSAGE
from handlers.utils import edit_or_send_message
from logger import logger
from services.payments.currency_rates import format_for_user
from ..constants import ALLOWED_TEMP_PAYMENT_STATES
from .service import (
PARITYPAY_METHODS,
generate_paritypay_payment_link,
process_callback_pay_paritypay,
router as service_router,
)
router = Router(name="paritypay_router")
router.include_router(service_router)
@router.callback_query(F.data == "pay_paritypay_sbp")
async def handle_pay_paritypay_sbp(callback_query: types.CallbackQuery, state: FSMContext, session: AsyncSession):
await process_callback_pay_paritypay(callback_query, state, session, method_name="sbp")
async def _handle_custom_amount_input_paritypay(
event,
session: AsyncSession,
method_name: str,
pay_button_text: str = PAY_2,
main_menu_text: str = MAIN_MENU,
):
message = event.message
from_user = event.from_user
tg_id = from_user.id
temp_data = await get_temporary_data(session, tg_id)
if not temp_data or temp_data["state"] not in ALLOWED_TEMP_PAYMENT_STATES:
await edit_or_send_message(target_message=message, text="❌ Не удалось получить данные для оплаты.")
return
amount = int(temp_data["data"].get("required_amount", 0))
if amount <= 0:
await edit_or_send_message(target_message=message, text="❌ Не удалось определить сумму оплаты.")
return
method = PARITYPAY_METHODS.get(method_name)
if not method or not method["enable"]:
await edit_or_send_message(
target_message=message,
text="❌ Способ оплаты ParityPay временно недоступен.",
)
return
if amount < method["min_amount"]:
await edit_or_send_message(
target_message=message,
text=f"❌ Минимальная сумма для оплаты — {method['min_amount']}₽.",
)
return
try:
payment_url = await generate_paritypay_payment_link(amount, tg_id, method, session)
if not payment_url:
await edit_or_send_message(
target_message=message,
text="❌ Произошла ошибка при создании платежа. Попробуйте позже или выберите другой способ оплаты.",
)
return
markup = InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text=pay_button_text, url=payment_url)],
[InlineKeyboardButton(text=main_menu_text, callback_data="profile")],
]
)
result = await session.execute(select(User.language_code).where(User.tg_id == tg_id))
language_code = result.scalar_one_or_none()
amount_text = await format_for_user(session, tg_id, float(amount), language_code, force_currency="RUB")
await edit_or_send_message(
target_message=message,
text=DEFAULT_PAYMENT_MESSAGE.format(amount=amount_text),
reply_markup=markup,
)
except Exception as e:
logger.error(f"Ошибка при создании платежа ParityPay {method_name} для пользователя {tg_id}: {e}")
await edit_or_send_message(
target_message=message,
text="Произошла ошибка при создании платежа. Попробуйте позже.",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[]),
)
async def handle_custom_amount_input_paritypay_sbp(
event,
session: AsyncSession,
pay_button_text: str = PAY_2,
main_menu_text: str = MAIN_MENU,
):
await _handle_custom_amount_input_paritypay(event, session, "sbp", pay_button_text, main_menu_text)
+417
View File
@@ -0,0 +1,417 @@
import hashlib
import hmac
import json
import time
import aiohttp
from aiogram import F, Router, types
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import (
PARITYPAY_API_URL,
PARITYPAY_CALLBACK_URL,
PARITYPAY_FAIL_URL,
PARITYPAY_SECRET_KEY,
PARITYPAY_SHOP_ID,
PARITYPAY_SUCCESS_URL,
PROVIDERS_ENABLED,
)
from database import register_pending_payment
from database.models import User
from handlers.buttons import BACK, PARITYPAY_SBP, PAY_2
from handlers.payments.keyboards import (
build_amounts_keyboard,
parse_amount_from_callback,
pay_keyboard,
payment_options_for_user,
)
from handlers.texts import (
PARITYPAY_PAYMENT_MESSAGE,
PARITYPAY_SBP_DESCRIPTION,
)
from handlers.utils import edit_or_send_message
from logger import logger
from services.payments.currency_rates import format_for_user
from services.payments.payment_links import register_payment_creator
router = Router()
async def get_user_language(session: AsyncSession, tg_id: int) -> str | None:
result = await session.execute(select(User.language_code).where(User.tg_id == tg_id))
return result.scalar_one_or_none()
class ReplenishBalanceParityPay(StatesGroup):
choosing_method = State()
choosing_amount = State()
waiting_for_payment_confirmation = State()
entering_custom_amount = State()
PARITYPAY_METHODS = {
"sbp": {
"enable": PROVIDERS_ENABLED.get("PARITYPAY_SBP", False),
"service": "sbp",
"button": PARITYPAY_SBP,
"desc": PARITYPAY_SBP_DESCRIPTION,
"min_amount": 10,
},
}
def _build_signature_string(payload: dict) -> str:
parts: list[str] = []
for key in sorted(payload.keys()):
value = payload[key]
if value is None:
parts.append("")
elif isinstance(value, bool):
parts.append("1" if value else "0")
else:
parts.append(str(value))
return "".join(parts)
def _sign_request(payload: dict) -> str:
sign_string = _build_signature_string(payload)
return hmac.new(
PARITYPAY_SECRET_KEY.encode("utf-8"),
sign_string.encode("utf-8"),
hashlib.sha256,
).hexdigest()
async def process_callback_pay_paritypay(
callback_query: types.CallbackQuery,
state: FSMContext,
session: AsyncSession,
method_name: str | None = None,
):
try:
tg_id = callback_query.from_user.id
logger.info(f"User {tg_id} initiated ParityPay payment.")
await state.clear()
if method_name:
method = PARITYPAY_METHODS.get(method_name)
if not method or not method["enable"]:
await edit_or_send_message(
target_message=callback_query.message,
text="Ошибка: выбранный способ оплаты недоступен.",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[]),
)
return
language_code = await get_user_language(session, tg_id)
opts = await payment_options_for_user(session, tg_id, language_code, force_currency="RUB")
builder = build_amounts_keyboard(
prefix=f"paritypay_{method_name}",
pattern="{prefix}_amount|{price}",
back_cb="balance",
custom_cb=f"paritypay_custom_amount|{method_name}",
opts=opts,
)
await edit_or_send_message(
target_message=callback_query.message,
text=method["desc"],
reply_markup=builder,
)
await state.update_data(
paritypay_method=method_name,
message_id=callback_query.message.message_id,
chat_id=callback_query.message.chat.id,
)
await state.set_state(ReplenishBalanceParityPay.choosing_amount)
return
builder = InlineKeyboardBuilder()
for name, method in PARITYPAY_METHODS.items():
if method["enable"]:
builder.row(InlineKeyboardButton(text=method["button"], callback_data=f"paritypay_method|{name}"))
builder.row(InlineKeyboardButton(text=BACK, callback_data="balance"))
await edit_or_send_message(
target_message=callback_query.message,
text="Выберите способ оплаты ParityPay:",
reply_markup=builder.as_markup(),
)
await state.update_data(
message_id=callback_query.message.message_id,
chat_id=callback_query.message.chat.id,
)
await state.set_state(ReplenishBalanceParityPay.choosing_method)
except Exception as e:
logger.error(f"Error in process_callback_pay_paritypay for user {callback_query.from_user.id}: {e}")
await callback_query.answer("Произошла ошибка при инициализации платежа. Попробуйте позже.", show_alert=True)
@router.callback_query(F.data.startswith("paritypay_method|"))
async def process_method_selection(callback_query: types.CallbackQuery, state: FSMContext, session: AsyncSession):
method_name = callback_query.data.split("|")[1]
method = PARITYPAY_METHODS.get(method_name)
if not method or not method["enable"]:
await edit_or_send_message(
target_message=callback_query.message,
text="Ошибка: выбранный способ оплаты недоступен.",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[]),
)
return
await state.update_data(paritypay_method=method_name)
tg_id = callback_query.from_user.id
language_code = await get_user_language(session, tg_id)
opts = await payment_options_for_user(session, tg_id, language_code, force_currency="RUB")
builder = build_amounts_keyboard(
prefix=f"paritypay_{method_name}",
pattern="{prefix}_amount|{price}",
back_cb="pay",
custom_cb=f"paritypay_custom_amount|{method_name}",
opts=opts,
)
await edit_or_send_message(
target_message=callback_query.message,
text=method["desc"],
reply_markup=builder,
)
await state.update_data(message_id=callback_query.message.message_id, chat_id=callback_query.message.chat.id)
await state.set_state(ReplenishBalanceParityPay.choosing_amount)
@router.callback_query(F.data.startswith("paritypay_custom_amount|"))
async def process_custom_amount_button(callback_query: types.CallbackQuery, state: FSMContext, session: AsyncSession):
method_name = callback_query.data.split("|")[1]
await state.update_data(paritypay_method=method_name)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data="pay_paritypay"))
await edit_or_send_message(
target_message=callback_query.message,
text="Пожалуйста, введите сумму пополнения в рублях (₽).",
reply_markup=builder.as_markup(),
)
await state.set_state(ReplenishBalanceParityPay.entering_custom_amount)
@router.message(ReplenishBalanceParityPay.entering_custom_amount)
async def handle_custom_amount_input(message: types.Message, state: FSMContext, session: AsyncSession):
data = await state.get_data()
method_name = data.get("paritypay_method")
method = PARITYPAY_METHODS.get(method_name)
if not method or not method["enable"]:
await edit_or_send_message(
target_message=message,
text="Ошибка: выбранный способ оплаты недоступен.",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[]),
)
return
try:
user_amount = int(message.text.strip())
if user_amount <= 0:
raise ValueError
if user_amount < method["min_amount"]:
await edit_or_send_message(
target_message=message,
text=f"❌ Минимальная сумма для оплаты — {method['min_amount']}₽.",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[]),
)
return
except Exception:
await edit_or_send_message(
target_message=message,
text="❌ Некорректная сумма. Введите целое число больше 0.",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[]),
)
return
await state.update_data(amount=user_amount)
payment_url = await generate_paritypay_payment_link(user_amount, message.from_user.id, method, session)
if not payment_url:
await edit_or_send_message(
target_message=message,
text="❌ Произошла ошибка при создании платежа. Попробуйте позже или выберите другой способ оплаты.",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[]),
)
return
confirm_keyboard = pay_keyboard(payment_url, pay_text=PAY_2, back_cb="balance")
tg_id = message.from_user.id
language_code = await get_user_language(session, tg_id)
amount_text = await format_for_user(session, tg_id, float(user_amount), language_code, force_currency="RUB")
await edit_or_send_message(
target_message=message,
text=PARITYPAY_PAYMENT_MESSAGE.format(amount=amount_text),
reply_markup=confirm_keyboard,
)
await state.set_state(ReplenishBalanceParityPay.waiting_for_payment_confirmation)
@router.callback_query(F.data.startswith("paritypay_sbp_amount|"))
async def process_amount_selection_sbp(callback_query: types.CallbackQuery, state: FSMContext, session: AsyncSession):
await _process_amount_selection(callback_query, state, session, "sbp")
async def _process_amount_selection(
callback_query: types.CallbackQuery,
state: FSMContext,
session: AsyncSession,
method_name: str,
):
amount = parse_amount_from_callback(callback_query.data, prefixes=[f"paritypay_{method_name}"])
if amount is None:
await edit_or_send_message(
target_message=callback_query.message,
text="Некорректная сумма.",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[]),
)
return
method = PARITYPAY_METHODS.get(method_name)
if not method or not method["enable"]:
await edit_or_send_message(
target_message=callback_query.message,
text="Ошибка: выбранный способ оплаты недоступен.",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[]),
)
return
if amount < method["min_amount"]:
await edit_or_send_message(
target_message=callback_query.message,
text=f"❌ Минимальная сумма для оплаты — {method['min_amount']}₽.",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[]),
)
return
await state.update_data(amount=amount)
payment_url = await generate_paritypay_payment_link(amount, callback_query.from_user.id, method, session)
if not payment_url:
await edit_or_send_message(
target_message=callback_query.message,
text="❌ Произошла ошибка при создании платежа. Попробуйте позже или выберите другой способ оплаты.",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[]),
)
return
confirm_keyboard = pay_keyboard(payment_url, pay_text=PAY_2, back_cb="balance")
tg_id = callback_query.from_user.id
language_code = await get_user_language(session, tg_id)
amount_text = await format_for_user(session, tg_id, float(amount), language_code, force_currency="RUB")
await edit_or_send_message(
target_message=callback_query.message,
text=PARITYPAY_PAYMENT_MESSAGE.format(amount=amount_text),
reply_markup=confirm_keyboard,
)
await state.set_state(ReplenishBalanceParityPay.waiting_for_payment_confirmation)
async def generate_paritypay_payment_link(
amount: int,
tg_id: int,
method: dict,
session: AsyncSession | None = None,
*,
order_id: str | None = None,
success_url: str | None = None,
fail_url: str | None = None,
metadata: dict | None = None,
) -> str | None:
unique_order_id = order_id or f"{int(time.time())}_{tg_id}"
payload = {
"shop_id": PARITYPAY_SHOP_ID,
"amount": int(amount),
"order_id": unique_order_id,
"service": method["service"],
"success_url": success_url or PARITYPAY_SUCCESS_URL or "",
"fail_url": fail_url or PARITYPAY_FAIL_URL or "",
"callback_url": PARITYPAY_CALLBACK_URL or "",
"user_hash": str(tg_id),
}
payload = {k: v for k, v in payload.items() if v not in (None, "")}
signature = _sign_request(payload)
headers = {"Content-Type": "application/json", "X-SIGNATURE": signature}
url = f"{PARITYPAY_API_URL.rstrip('/')}/invoice/create"
timeout = aiohttp.ClientTimeout(total=30, connect=10)
try:
async with aiohttp.ClientSession(timeout=timeout) as http_session:
async with http_session.post(url, headers=headers, data=json.dumps(payload)) as resp:
text = await resp.text()
if resp.status != 200:
logger.error(f"ParityPay API error: status={resp.status}, body={text}")
return None
try:
resp_json = json.loads(text)
except Exception as e:
logger.error(f"ParityPay: невалидный JSON в ответе ({e}): {text}")
return None
if "error" in resp_json:
logger.error(f"ParityPay error: {resp_json}")
return None
payment_url = resp_json.get("link")
if not payment_url:
logger.error(f"ParityPay: пустая ссылка в ответе: {resp_json}")
return None
await register_pending_payment(
payment_id=unique_order_id,
tg_id=tg_id,
amount=float(amount),
payment_system="paritypay",
currency="RUB",
metadata=metadata,
)
logger.info(f"ParityPay payment URL created for user {tg_id}, order_id={unique_order_id}")
return payment_url
except Exception as e:
logger.error(f"Error creating ParityPay payment: {e}")
return None
def _create_link_factory(method_name: str):
async def create_link(
session: AsyncSession,
tg_id: int,
amount: float,
currency: str,
success_url: str | None,
failure_url: str | None,
metadata: dict | None,
) -> tuple[str, str | None]:
if currency != "RUB":
raise ValueError("ParityPay поддерживает только RUB")
method = PARITYPAY_METHODS.get(method_name)
if not method or not method.get("enable"):
raise ValueError("Способ оплаты ParityPay недоступен")
amount_int = int(amount)
if amount_int < method["min_amount"]:
raise ValueError(f"Минимальная сумма — {method['min_amount']}")
order_id = f"{int(time.time())}_{tg_id}"
url = await generate_paritypay_payment_link(
amount_int,
tg_id,
method,
session,
order_id=order_id,
success_url=success_url,
fail_url=failure_url,
metadata=metadata,
)
if not url:
raise ValueError("Не удалось создать платёж ParityPay")
return (url, order_id)
return create_link
register_payment_creator("PARITYPAY_SBP", _create_link_factory("sbp"))
+149
View File
@@ -0,0 +1,149 @@
import hashlib
import hmac
import json
from aiohttp import web
from config import PARITYPAY_SECRET_KEY
from core.webhook_abuse import (
get_webhook_client_ip,
is_webhook_ip_blocked,
record_webhook_signature_failure,
)
from logger import logger
from services.payments.pipeline import (
ParsedPayment,
process_cancelled_payment,
process_success_payment,
)
_PROVIDER = "paritypay"
def _build_signature_string(payload: dict) -> str:
parts: list[str] = []
for key in sorted(payload.keys()):
value = payload[key]
if value is None:
parts.append("")
elif isinstance(value, bool):
parts.append("1" if value else "0")
else:
parts.append(str(value))
return "".join(parts)
def verify_paritypay_signature(payload: dict, signature: str) -> bool:
try:
sign_string = _build_signature_string(payload)
expected = hmac.new(
PARITYPAY_SECRET_KEY.encode("utf-8"),
sign_string.encode("utf-8"),
hashlib.sha256,
).hexdigest()
result = hmac.compare_digest(expected, signature)
if not result:
logger.error(f"[ParityPay] Signature mismatch. Expected: {expected}, Got: {signature}")
return result
except Exception as e:
logger.error(f"[ParityPay] Ошибка проверки подписи: {e}")
return False
def _parse_tg_id_from_order(order_id: str) -> int | None:
if not order_id:
return None
parts = order_id.split("_")
if len(parts) < 2:
return None
try:
return int(parts[1])
except (ValueError, TypeError):
return None
async def paritypay_webhook(request: web.Request):
try:
ip = get_webhook_client_ip(request)
if await is_webhook_ip_blocked(ip):
return web.Response(status=429)
raw_body = await request.read()
try:
data = json.loads(raw_body)
except Exception as e:
logger.error(f"[ParityPay] Невалидный JSON в webhook: {e}")
return web.Response(status=400)
signature = request.headers.get("X-SIGNATURE") or request.headers.get("X-Signature") or ""
if not signature:
logger.error("[ParityPay] Нет заголовка X-SIGNATURE")
await record_webhook_signature_failure(ip)
return web.Response(status=400)
if not verify_paritypay_signature(data, signature):
await record_webhook_signature_failure(ip)
return web.Response(status=400)
logger.info(f"[ParityPay] webhook: {json.dumps(data, ensure_ascii=False)}")
order_id = str(data.get("order_id") or "")
invoice_id = str(data.get("id") or "")
status = str(data.get("status") or "").upper()
amount_raw = data.get("amount")
service = data.get("service")
if not order_id:
logger.error(f"[ParityPay] Пустой order_id в webhook: {data}")
return web.Response(status=400)
try:
amount = float(amount_raw) if amount_raw is not None else 0.0
except (TypeError, ValueError):
amount = 0.0
tg_id = _parse_tg_id_from_order(order_id)
if status == "PAID":
if tg_id is None:
logger.error(f"[ParityPay] Не удалось определить tg_id из order_id={order_id}")
return web.Response(status=400)
metadata_patch = {
"provider": _PROVIDER,
"paritypay_invoice_id": invoice_id or None,
"paritypay_service": service,
}
parsed = ParsedPayment(
payment_id=order_id,
tg_id=int(tg_id),
amount=float(amount),
currency="RUB",
metadata=metadata_patch,
)
result = await process_success_payment(_PROVIDER, parsed, metadata_patch=metadata_patch)
if not result.ok:
logger.error(f"[ParityPay] Pipeline вернул ошибку: {result.error}, order_id={order_id}")
return web.Response(status=500)
logger.info(f"[ParityPay] Платёж обработан: tg_id={tg_id}, amount={amount:.2f} ₽, order_id={order_id}")
return web.Response(status=200, text="OK")
if status in ("EXPIRED", "REFUNDED"):
new_status = "failed" if status == "EXPIRED" else "refunded"
parsed = ParsedPayment(
payment_id=order_id,
tg_id=int(tg_id) if tg_id is not None else None,
amount=float(amount),
currency="RUB",
)
await process_cancelled_payment(_PROVIDER, parsed, new_status=new_status)
logger.warning(f"[ParityPay] Транзакция {status}: order_id={order_id}")
return web.Response(status=200, text="OK")
logger.info(f"[ParityPay] Промежуточный статус '{status}' для order_id={order_id}, игнор")
return web.Response(status=200, text="OK")
except Exception as e:
logger.error(f"[ParityPay] Ошибка в webhook: {e}", exc_info=True)
return web.Response(status=500)
+8
View File
@@ -43,6 +43,13 @@ PROVIDERS_BASE: dict[str, dict[str, Any]] = {
"module": "wata",
"order": 55,
},
"PARITYPAY_SBP": {
"currency": "RUB",
"value": "pay_paritypay_sbp",
"fast": "handle_custom_amount_input_paritypay_sbp",
"module": "paritypay",
"order": 57,
},
"TRIBUTE": {
"currency": "RUB+USD",
"value": "pay_tribute",
@@ -90,6 +97,7 @@ WEB_LINK_PROVIDER_IDS = (
"KASSAI_SBP",
"WATA_RU",
"WATA_INT",
"PARITYPAY_SBP",
"HELEKET",
"FREEKASSA",
"CRYPTOBOT",
+3
View File
@@ -2,6 +2,7 @@ from aiohttp.web_urldispatcher import UrlDispatcher
from services.payments.heleket.webhook import heleket_webhook
from services.payments.kassai.webhook import kassai_webhook
from services.payments.paritypay.webhook import paritypay_webhook
from services.payments.wata.webhook import wata_webhook
from utils.modules_loader import load_module_webhooks
@@ -9,12 +10,14 @@ from utils.modules_loader import load_module_webhooks
KASSAI_WEBHOOK_PATH = "/kassai/webhook"
HELEKET_WEBHOOK_PATH = "/heleket/webhook"
WATA_WEBHOOK_PATH = "/wata/webhook"
PARITYPAY_WEBHOOK_PATH = "/paritypay/webhook"
async def register_web_routes(router: UrlDispatcher) -> None:
router.add_post(KASSAI_WEBHOOK_PATH, kassai_webhook)
router.add_post(HELEKET_WEBHOOK_PATH, heleket_webhook)
router.add_post(WATA_WEBHOOK_PATH, wata_webhook)
router.add_post(PARITYPAY_WEBHOOK_PATH, paritypay_webhook)
try:
module_webhooks = load_module_webhooks()