mirror of
https://github.com/wasrusgen/wasrusgen1-crm.git
synced 2026-06-03 14:24:47 +00:00
feat(СБП): динамический QR Сбера — кнопка на этапе, демо-режим, webhook + ручное подтверждение
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2fc91433da
commit
c56d7ebf00
@ -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
|
||||
|
||||
# Версии и тексты юридических документов (хеш фиксируется при акцепте)
|
||||
|
||||
@ -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?`<img src="${d.qr_image}" style="width:220px;height:220px;border-radius:12px">`:`<div style="width:220px;height:220px;margin:0 auto;border:2px dashed #A7F3D0;border-radius:12px;display:flex;align-items:center;justify-content:center;text-align:center;color:#6B7280;font-size:12px;padding:16px">QR придёт от Сбера<br>после настройки реквизитов<br><br>(демо-режим)</div>`;
|
||||
ov.innerHTML=`<div style="background:#fff;border-radius:16px;padding:24px;max-width:340px;width:90%;text-align:center;font-family:Inter">
|
||||
<div style="font-size:15px;font-weight:800;font-family:Montserrat;margin-bottom:4px">📱 СБП · ${esc(name)}</div>
|
||||
<div style="font-size:22px;font-weight:800;color:var(--primary);margin-bottom:14px">${money(amount)}</div>
|
||||
${qrBox}
|
||||
${d.demo?`<div style="font-size:11px;color:#92400E;background:#FEF3C7;border-radius:8px;padding:8px;margin-top:12px">${esc(d.message||'Демо-режим')}</div>`:''}
|
||||
${payload?`<div style="margin-top:12px"><a href="${esc(payload)}" target="_blank" style="font-size:11px;color:var(--primary);word-break:break-all">${esc(payload)}</a></div>`:''}
|
||||
<div style="display:flex;gap:8px;margin-top:16px">
|
||||
<button class="cp-btn cp-a" style="flex:1" onclick="confirmSberQR('${d.order_id}')">✓ Оплата получена</button>
|
||||
<button class="cp-btn cp-r" onclick="document.getElementById('qrModal').remove()">Закрыть</button>
|
||||
</div>
|
||||
</div>`;
|
||||
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=`<span style="font-size:10px;font-weight:700;color:#92400E;background:#FEF3C7;padding:1px 6px;border-radius:4px;margin-left:4px">Доступен к оплате</span>`;
|
||||
sub=`<div style="font-size:11px;color:var(--muted);margin-top:2px">${esc(s.desc)}</div>`;
|
||||
action=`<button class="cp-btn cp-a" style="white-space:nowrap" onclick="markStagePayInput('${s.key}')">Оплата · ${money(price)}</button>`;
|
||||
action=`<button class="cp-btn cp-r" style="white-space:nowrap;padding:7px 11px" onclick="sberQR('${s.key}')" title="Выставить СБП QR">📱 QR</button><button class="cp-btn cp-a" style="white-space:nowrap" onclick="markStagePayInput('${s.key}')">Оплата · ${money(price)}</button>`;
|
||||
} else {
|
||||
badge=`<span style="font-size:10px;font-weight:700;color:#9CA3AF;background:#F3F4F6;padding:1px 6px;border-radius:4px;margin-left:4px">🔒 Ожидает</span>`;
|
||||
sub=`<div style="font-size:11px;color:#CBD5E1;margin-top:2px">${esc(s.desc)}</div>`;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user