From c56d7ebf008738842a481427202bfb7f428711d6 Mon Sep 17 00:00:00 2001 From: wasrusgen Date: Mon, 1 Jun 2026 09:12:11 +0300 Subject: [PATCH] =?UTF-8?q?feat(=D0=A1=D0=91=D0=9F):=20=D0=B4=D0=B8=D0=BD?= =?UTF-8?q?=D0=B0=D0=BC=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=B8=D0=B9=20QR=20?= =?UTF-8?q?=D0=A1=D0=B1=D0=B5=D1=80=D0=B0=20=E2=80=94=20=D0=BA=D0=BD=D0=BE?= =?UTF-8?q?=D0=BF=D0=BA=D0=B0=20=D0=BD=D0=B0=20=D1=8D=D1=82=D0=B0=D0=BF?= =?UTF-8?q?=D0=B5,=20=D0=B4=D0=B5=D0=BC=D0=BE-=D1=80=D0=B5=D0=B6=D0=B8?= =?UTF-8?q?=D0=BC,=20webhook=20+=20=D1=80=D1=83=D1=87=D0=BD=D0=BE=D0=B5=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5=D1=80=D0=B6=D0=B4=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/elena_app.py | 101 +++++++++++++++++++++++++++++++++++++++++++ docs/crm.html | 39 ++++++++++++++++- 2 files changed, 139 insertions(+), 1 deletion(-) diff --git a/backend/elena_app.py b/backend/elena_app.py index a669557..aff6573 100644 --- a/backend/elena_app.py +++ b/backend/elena_app.py @@ -653,6 +653,107 @@ def update_crm(): save_artifact(proj["id"], "crm", crm) return jsonify({"ok": True, "crm": crm}) +# ── СБП (Сбер) — динамический QR на оплату этапа ───────────────── +SBER_SBP_ENABLED = os.getenv("SBER_SBP_ENABLED", "0") == "1" +SBER_SBP_URL = os.getenv("SBER_SBP_URL", "") # базовый URL API СБП Сбера +SBER_SBP_TOKEN = os.getenv("SBER_SBP_TOKEN", "") # API-токен / secret key +SBER_SBP_MERCHANT = os.getenv("SBER_SBP_MERCHANT", "") # merchantId / TID +SBER_SBP_MEMBER = os.getenv("SBER_SBP_MEMBER", "") # memberId (банк-участник СБП) +SBP_WEBHOOK_URL = "https://wasrusgen1.ru/consulting/api/payment/sber-webhook" + +@app.route("/api/payment/sber-qr", methods=["POST"]) +def sber_qr(): + data = request.get_json(force=True) or {} + proj = get_project(data.get("token")) + if not proj: + return jsonify({"error": "project not found"}), 404 + amount = float(data.get("amount") or 0) + if amount <= 0: + return jsonify({"error": "bad amount"}), 400 + stage = data.get("stage_key") or "" + desc = data.get("description") or "Оплата консалтинга" + order_id = "ORD-" + secrets.token_hex(6) + crm = latest_artifact(proj["id"], "crm") or {} + crm.setdefault("sber_qr", {})[order_id] = {"stage": stage, "amount": amount, "status": "pending"} + save_artifact(proj["id"], "crm", crm) + if not SBER_SBP_ENABLED: + # демо-режим: реквизитов СберБизнес ещё нет — отдаём заглушку, UX работает + return jsonify({ + "demo": True, "order_id": order_id, "amount": amount, + "qr_payload": "https://qr.nspk.ru/DEMO" + order_id.replace("ORD-", ""), + "message": "Демо-режим: СБП не настроен. Добавьте SBER_SBP_* в .env." + }) + # боевой режим — регистрация динамического QR в Сбере + try: + import urllib.request + payload = { + "merchantId": SBER_SBP_MERCHANT, "memberId": SBER_SBP_MEMBER, + "amount": int(round(amount * 100)), "currency": "643", + "orderId": order_id, "description": desc[:140], + "callbackUrl": SBP_WEBHOOK_URL, + } + # NB: путь endpoint уточнить по документации СберБизнес (СБП C2B dynamic QR) + req = urllib.request.Request( + SBER_SBP_URL.rstrip("/") + "/sbp/c2b/qr/dynamic", + data=json.dumps(payload).encode("utf-8"), + headers={"Authorization": "Bearer " + SBER_SBP_TOKEN, "Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=20) as r: + res = json.loads(r.read().decode("utf-8")) + return jsonify({"order_id": order_id, "amount": amount, + "qr_payload": res.get("payload") or res.get("qrUrl"), + "qr_image": res.get("qrImage") or res.get("image"), "raw": res}) + except Exception as e: + return jsonify({"error": "sber_error", "detail": str(e)}), 502 + +@app.route("/api/payment/sber-webhook", methods=["POST"]) +def sber_webhook(): + data = request.get_json(force=True) or {} + order_id = data.get("orderId") or data.get("order") or data.get("orderNumber") + status = str(data.get("status") or data.get("paymentStatus") or "").lower() + paid_ok = status in ("paid", "success", "confirmed", "approved", "deposited") + if not order_id: + return jsonify({"error": "no order"}), 400 + today = datetime.now(timezone.utc).strftime("%Y-%m-%d") + for row in db().execute("SELECT id FROM projects").fetchall(): + crm = latest_artifact(row["id"], "crm") + if crm and order_id in (crm.get("sber_qr") or {}): + rec = crm["sber_qr"][order_id] + if paid_ok and rec.get("status") != "paid": + rec["status"] = "paid" + amount = rec.get("amount", 0); stage = rec.get("stage") or None + crm.setdefault("payments", []).append({"date": today, "amount": amount, + "note": "СБП (Сбер)", "stage": stage, "method": "sbp"}) + if stage: + crm.setdefault("stage_payments", {})[stage] = {"amount": amount, "date": today, "method": "sbp"} + crm["paid_amount"] = sum(p.get("amount", 0) for p in crm.get("payments", [])) + save_artifact(row["id"], "crm", crm) + return jsonify({"ok": True}) + return jsonify({"error": "order not found"}), 404 + +# ── Ручное подтверждение оплаты по QR (демо / нал-у-стойки) ───── +@app.route("/api/payment/sber-confirm", methods=["POST"]) +def sber_confirm(): + data = request.get_json(force=True) or {} + proj = get_project(data.get("token")) + if not proj: + return jsonify({"error": "project not found"}), 404 + order_id = data.get("order_id") + crm = latest_artifact(proj["id"], "crm") or {} + rec = (crm.get("sber_qr") or {}).get(order_id) + if not rec: + return jsonify({"error": "order not found"}), 404 + if rec.get("status") != "paid": + today = datetime.now(timezone.utc).strftime("%Y-%m-%d") + rec["status"] = "paid" + amount = rec.get("amount", 0); stage = rec.get("stage") or None + crm.setdefault("payments", []).append({"date": today, "amount": amount, + "note": "СБП (Сбер)", "stage": stage, "method": "sbp"}) + if stage: + crm.setdefault("stage_payments", {})[stage] = {"amount": amount, "date": today, "method": "sbp"} + crm["paid_amount"] = sum(p.get("amount", 0) for p in crm.get("payments", [])) + save_artifact(proj["id"], "crm", crm) + return jsonify({"ok": True, "crm": crm}) + import hashlib # Версии и тексты юридических документов (хеш фиксируется при акцепте) diff --git a/docs/crm.html b/docs/crm.html index 52313e8..3ca2978 100644 --- a/docs/crm.html +++ b/docs/crm.html @@ -538,6 +538,43 @@ async function confirmStagePay(k){ ]); syncPaymentReminders();renderClient(); } +// ── СБП QR (Сбер) ── +async function sberQR(k){ + const sp=getStagePrices()||{}; const amount=sp[k]||0; + if(!amount){alert('У этапа нет цены');return;} + const name=CLIENT_STAGES.find(s=>s.key===k).name; + try{ + const r=await fetch(`${API}/api/payment/sber-qr`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:current,stage_key:k,amount,description:'Этап: '+name})}); + const d=await r.json(); + if(d.error){alert('Ошибка СБП: '+(d.detail||d.error));return;} + showQRModal(d,name,amount); + }catch(e){alert('Ошибка: '+e.message);} +} +function showQRModal(d,name,amount){ + const payload=d.qr_payload||d.qr_url||''; + const ov=document.createElement('div'); + ov.id='qrModal'; + ov.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:9999'; + const qrBox=d.qr_image?``:`
QR придёт от Сбера
после настройки реквизитов

(демо-режим)
`; + ov.innerHTML=`
+
📱 СБП · ${esc(name)}
+
${money(amount)}
+ ${qrBox} + ${d.demo?`
${esc(d.message||'Демо-режим')}
`:''} + ${payload?`
${esc(payload)}
`:''} +
+ + +
+
`; + ov.onclick=e=>{if(e.target===ov)ov.remove();}; + document.body.appendChild(ov); +} +async function confirmSberQR(orderId){ + try{await fetch(`${API}/api/payment/sber-confirm`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:current,order_id:orderId})});}catch(e){} + const m=document.getElementById('qrModal');if(m)m.remove(); + await openClient(current); mainTab='payments'; renderMainPanel(); +} async function unStagePay(k){ const pays=getStagePays();if(!pays[k])return; if(!confirm('Отменить оплату этапа «'+CLIENT_STAGES.find(s=>s.key===k).name+'»? Связанный платёж удалится из реестра.'))return; @@ -604,7 +641,7 @@ function renderPaymentPlan(){ } else if(done){ badge=`Доступен к оплате`; sub=`
${esc(s.desc)}
`; - action=``; + action=``; } else { badge=`🔒 Ожидает`; sub=`
${esc(s.desc)}
`;