Приём платежей ЮKassa: поддержка картой + тариф API Business

- Бэкенд: /api/pay/create, /api/yookassa/webhook (перепроверка через API),
  /api/pay/status (активный фоллбэк без вебхука), таблица WEB_PAYMENTS,
  колонка WEB_API_KEYS.paid_until, активация plan=business на 30 дней
- Сайт: оплата картой в блоке поддержки (сумма + email для чека 54-ФЗ)
- /developers: оплата тарифа Business, i18n оплаты (7 языков)
- Ключи только из env (YOOKASSA_SHOP_ID/SECRET)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
root
2026-07-03 07:26:39 +00:00
parent f0dd8788b3
commit 7c91ec7cb1
2 changed files with 380 additions and 1 deletions

View File

@@ -38,6 +38,17 @@ AUTH_TOKEN_TTL = 600 # 10 минут на авторизацию чер
AUTH_REQUIRED_PAGES = 100
AUTH_REQUIRED_MB = 50.0
# ── ЮKassa (платежи) ──────────────────────────────────────────────────────────
YOOKASSA_SHOP_ID = os.getenv("YOOKASSA_SHOP_ID", "")
YOOKASSA_SECRET_KEY = os.getenv("YOOKASSA_SECRET_KEY", "")
YOOKASSA_API = "https://api.yookassa.ru/v3"
SITE_BASE_URL = os.getenv("SITE_BASE_URL", "https://pdf2scan.online")
PAY_MIN_RUB = 10.0
PAY_MAX_RUB = 100000.0
# Тариф API Pro (правится здесь). Активируется при purpose=api_pro.
API_PRO_PRICE_RUB = float(os.getenv("API_PRO_PRICE_RUB", "299"))
API_PRO_DAYS = 30
# Чанковая загрузка (туннель схлопывает крупные передачи — грузим кусками ≤6 МБ)
UPLOAD_DIR = f"{BOT_TEMP}uploads/" # временные .part-файлы
MAX_CHUNK_BYTES = 12 * 1024 * 1024 # защита: один кусок не больше 12 МБ
@@ -231,6 +242,29 @@ def _db_ensure_auth_table():
last_req REAL
)
""")
# Платежи ЮKassa. status: pending/succeeded/canceled. purpose: donate/api_pro.
con.execute("""
CREATE TABLE IF NOT EXISTS WEB_PAYMENTS (
payment_id TEXT PRIMARY KEY,
amount REAL,
currency TEXT,
status TEXT,
purpose TEXT,
api_key TEXT,
owner_id INTEGER,
email TEXT,
created_at REAL,
paid_at REAL
)
""")
# paid_until у ключей — до какого времени активен платный тариф
try:
cur = con.execute("PRAGMA table_info(WEB_API_KEYS)")
kcols = {row[1] for row in cur.fetchall()}
if "paid_until" not in kcols:
con.execute("ALTER TABLE WEB_API_KEYS ADD COLUMN paid_until REAL DEFAULT 0")
except Exception as _kmig:
print(f"WEB_API_KEYS migrate error: {_kmig}")
# Авто-миграция FILES: колонки для OCR и владельца веб-задания
# (личный кабинет «Мои документы»). ADD COLUMN идемпотентен по факту наличия.
try:
@@ -1455,6 +1489,174 @@ async def submit_zip_chunked(
return {"jobs": created}
# ══════════════════════════════════════════════════════════════════════════════
# Платежи ЮKassa — /api/pay/*, вебхук
# ══════════════════════════════════════════════════════════════════════════════
import base64 as _b64
import json as _pjson
import urllib.request as _urlreq
import urllib.error as _urlerr
def _yk_auth_header() -> str:
raw = f"{YOOKASSA_SHOP_ID}:{YOOKASSA_SECRET_KEY}".encode()
return "Basic " + _b64.b64encode(raw).decode()
def _yk_request(method: str, path: str, body: dict | None = None, idempotence_key: str | None = None) -> dict:
"""Синхронный вызов API ЮKassa (stdlib urllib). Вызывать через asyncio.to_thread."""
data = _pjson.dumps(body).encode() if body is not None else None
req = _urlreq.Request(f"{YOOKASSA_API}{path}", data=data, method=method)
req.add_header("Authorization", _yk_auth_header())
req.add_header("Content-Type", "application/json")
if idempotence_key:
req.add_header("Idempotence-Key", idempotence_key)
try:
with _urlreq.urlopen(req, timeout=25) as resp:
return _pjson.loads(resp.read().decode())
except _urlerr.HTTPError as e:
raise HTTPException(status_code=502, detail=f"ЮKassa {e.code}: {e.read().decode(errors='ignore')[:300]}")
except Exception as e:
raise HTTPException(status_code=502, detail=f"ЮKassa недоступна: {e}")
def _db_record_payment(payment_id, amount, currency, status, purpose, api_key, email):
"""Пишет/обновляет платёж, сохраняя paid_at и email при повторной записи из вебхука."""
try:
with sqlite3.connect(DB_PATH, timeout=10) as con:
con.execute(
"INSERT INTO WEB_PAYMENTS (payment_id, amount, currency, status, purpose, api_key, email, created_at, paid_at) "
"VALUES (?,?,?,?,?,?,?,?,0) "
"ON CONFLICT(payment_id) DO UPDATE SET status=excluded.status, "
"email=COALESCE(NULLIF(excluded.email,''), WEB_PAYMENTS.email)",
(payment_id, amount, currency, status, purpose, api_key, email, time.time()),
)
con.commit()
except Exception as e:
print(f"record payment error: {e}")
def _db_mark_payment_paid(payment_id, purpose, api_key):
try:
with sqlite3.connect(DB_PATH, timeout=10) as con:
con.execute("UPDATE WEB_PAYMENTS SET status='succeeded', paid_at=? WHERE payment_id=?",
(time.time(), payment_id))
if purpose == "api_pro" and api_key:
cur = con.execute("SELECT paid_until FROM WEB_API_KEYS WHERE api_key=?", (api_key,))
row = cur.fetchone()
base = max(time.time(), (row[0] or 0)) if row else time.time()
until = base + API_PRO_DAYS * 86400
con.execute("UPDATE WEB_API_KEYS SET plan='business', paid_until=? WHERE api_key=?",
(until, api_key))
con.commit()
except Exception as e:
print(f"mark paid error: {e}")
@app.post("/api/pay/create")
async def pay_create(payload: dict):
"""Создаёт платёж ЮKassa. body: {amount, purpose, email?, api_key?} → confirmation_url."""
if not YOOKASSA_SHOP_ID or not YOOKASSA_SECRET_KEY:
raise HTTPException(status_code=503, detail="Приём платежей не настроен.")
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="Bad payload")
purpose = str(payload.get("purpose") or "donate")[:32]
email = (payload.get("email") or "").strip()[:128]
api_key = (payload.get("api_key") or "").strip()[:80] or None
if purpose == "api_pro":
amount = API_PRO_PRICE_RUB
description = "pdf2scan API — тариф Business (30 дней)"
if api_key and not _db_get_api_key(api_key):
raise HTTPException(status_code=400, detail="Неизвестный API-ключ")
else:
try:
amount = round(float(payload.get("amount") or 0), 2)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="Некорректная сумма")
if amount < PAY_MIN_RUB or amount > PAY_MAX_RUB:
raise HTTPException(status_code=400, detail=f"Сумма {PAY_MIN_RUB:.0f}{PAY_MAX_RUB:.0f}")
description = "Поддержка проекта pdf2scan"
value = f"{amount:.2f}"
body = {
"amount": {"value": value, "currency": "RUB"},
"capture": True,
"confirmation": {"type": "redirect", "return_url": f"{SITE_BASE_URL}/?paid={purpose}"},
"description": description,
"metadata": {"purpose": purpose, "api_key": api_key or "", "email": email},
}
if email: # чек 54-ФЗ (нужна подключённая онлайн-касса в ЮKassa)
body["receipt"] = {
"customer": {"email": email},
"items": [{
"description": description[:128], "quantity": "1.00",
"amount": {"value": value, "currency": "RUB"},
"vat_code": 1, "payment_mode": "full_payment", "payment_subject": "service",
}],
}
result = await asyncio.to_thread(_yk_request, "POST", "/payments", body, secrets.token_hex(16))
pid = result.get("id")
conf = (result.get("confirmation") or {}).get("confirmation_url")
_db_record_payment(pid, amount, "RUB", result.get("status", "pending"), purpose, api_key, email)
return {"payment_id": pid, "confirmation_url": conf, "status": result.get("status")}
@app.post("/api/yookassa/webhook")
async def yookassa_webhook(payload: dict):
"""Вебхук ЮKassa. Тело НЕ доверяем — перепроверяем платёж через API."""
obj = payload.get("object") if isinstance(payload, dict) else None
pid = (obj or {}).get("id")
if not pid:
return {"ok": True}
try:
real = await asyncio.to_thread(_yk_request, "GET", f"/payments/{pid}", None, None)
except Exception:
return {"ok": True}
status = real.get("status")
meta = real.get("metadata") or {}
purpose = meta.get("purpose") or "donate"
api_key = meta.get("api_key") or None
amount = float((real.get("amount") or {}).get("value") or 0)
_db_record_payment(pid, amount, "RUB", status, purpose, api_key, meta.get("email") or "")
if status == "succeeded":
_db_mark_payment_paid(pid, purpose, api_key)
return {"ok": True}
@app.get("/api/pay/status/{payment_id}")
async def pay_status(payment_id: str):
"""Статус платежа. Если в БД ещё pending — активно перепроверяем через API ЮKassa
и активируем (фоллбэк, когда вебхук не настроен/задержался)."""
db_status, purpose, api_key = None, "donate", None
try:
with sqlite3.connect(DB_PATH, timeout=10) as con:
cur = con.execute("SELECT status, purpose, api_key FROM WEB_PAYMENTS WHERE payment_id=?", (payment_id,))
row = cur.fetchone()
if row:
db_status, purpose, api_key = row[0], row[1], row[2]
except Exception:
pass
if db_status == "succeeded":
return {"status": "succeeded", "purpose": purpose}
if not db_status:
return {"status": "unknown"}
try:
real = await asyncio.to_thread(_yk_request, "GET", f"/payments/{payment_id}", None, None)
status = real.get("status")
if status and status != db_status:
meta = real.get("metadata") or {}
amount = float((real.get("amount") or {}).get("value") or 0)
rp = meta.get("purpose") or purpose
rk = meta.get("api_key") or api_key
_db_record_payment(payment_id, amount, "RUB", status, rp, rk, meta.get("email") or "")
if status == "succeeded":
_db_mark_payment_paid(payment_id, rp, rk)
return {"status": status or db_status, "purpose": purpose}
except Exception:
return {"status": db_status, "purpose": purpose}
_API_DOCS_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
@@ -1514,6 +1716,16 @@ _API_DOCS_HTML = """<!DOCTYPE html>
<p style="margin-top:14px">Либо напишите <a href="https://t.me/berkio" target="_blank" rel="noopener">@berkio</a> — выдадим ключ вручную.</p>
</div>
<h2>Тариф / Plan</h2>
<div class="card">
<p><strong>Free</strong> — 60 запросов/мин, для проб. &nbsp; <strong>Business</strong> — 300 запросов/мин, приоритет, крупные файлы.</p>
<p style="margin:6px 0 12px"><strong id="pro-price">{{PRO_PRICE}}</strong> ₽ / 30 дней. Оплата картой через ЮKassa (чек на email).</p>
<input type="text" id="pay-key" placeholder="ваш API-ключ (sk_...)" style="width:100%;max-width:420px;padding:9px 12px;border:1px solid #22314a;background:#0a2540;color:#e7eefc;border-radius:8px;margin-bottom:8px;font-family:monospace;font-size:13px"><br>
<input type="email" id="pay-key-email" placeholder="email для чека" style="width:100%;max-width:280px;padding:9px 12px;border:1px solid #22314a;background:#0a2540;color:#e7eefc;border-radius:8px;margin-bottom:10px;font-size:14px"><br>
<button id="pay-pro-btn" onclick="payPro()" style="background:var(--acc);color:#04121f;border:none;border-radius:10px;padding:11px 22px;font-size:15px;font-weight:700;cursor:pointer">💳 Оплатить Business</button>
<div id="pay-pro-msg" style="margin-top:10px;color:var(--sub);font-size:14px"></div>
</div>
<h2>2. Отправить файл / Submit a file</h2>
<div class="card">
<p><span class="meth post">POST</span><code>/api/v1/scan</code> &nbsp;— multipart/form-data</p>
@@ -1610,6 +1822,7 @@ _API_DOCS_HTML = """<!DOCTYPE html>
if (kd.api_key){
document.getElementById('genkey-value').textContent = kd.api_key;
document.getElementById('genkey-result').style.display = 'block';
var pk = document.getElementById('pay-key'); if (pk) pk.value = kd.api_key;
setStatus('Готово! Лимит: ' + kd.rate_limit_per_min + ' запросов/мин.');
} else {
setStatus('Не удалось выпустить ключ: ' + (kd.detail || 'ошибка'));
@@ -1624,6 +1837,23 @@ _API_DOCS_HTML = """<!DOCTYPE html>
setStatus('Ошибка: ' + e); btn.disabled = false;
}
}
async function payPro(){
var key = (document.getElementById('pay-key').value||'').trim();
var email = (document.getElementById('pay-key-email').value||'').trim();
var msg = document.getElementById('pay-pro-msg');
var btn = document.getElementById('pay-pro-btn');
if (!/^sk_/.test(key)){ msg.textContent = 'Укажите ваш API-ключ (sk_...)'; return; }
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)){ msg.textContent = 'Укажите корректный email для чека'; return; }
btn.disabled = true; msg.textContent = 'Создаём платёж…';
try{
var r = await fetch('/api/pay/create', {method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({purpose:'api_pro', api_key:key, email:email})});
var d = await r.json();
if (d.confirmation_url){ localStorage.setItem('pdf2scan_pay_id', d.payment_id); location.href = d.confirmation_url; }
else { msg.textContent = d.detail || 'Не удалось создать платёж'; btn.disabled = false; }
}catch(e){ msg.textContent = 'Ошибка сети'; btn.disabled = false; }
}
</script>
</body>
</html>"""
@@ -1632,4 +1862,4 @@ _API_DOCS_HTML = """<!DOCTYPE html>
@app.get("/developers", response_class=HTMLResponse)
@app.get("/api-docs", response_class=HTMLResponse)
async def developers_docs():
return _API_DOCS_HTML
return _API_DOCS_HTML.replace("{{PRO_PRICE}}", str(int(API_PRO_PRICE_RUB)))