diff --git a/app/main.py b/app/main.py index 6674ee3..5a101b6 100644 --- a/app/main.py +++ b/app/main.py @@ -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 = """ @@ -1514,6 +1716,16 @@ _API_DOCS_HTML = """

Либо напишите @berkio — выдадим ключ вручную.

+

Тариф / Plan

+
+

Free — 60 запросов/мин, для проб.   Business — 300 запросов/мин, приоритет, крупные файлы.

+

{{PRO_PRICE}} ₽ / 30 дней. Оплата картой через ЮKassa (чек на email).

+
+
+ +
+
+

2. Отправить файл / Submit a file

POST/api/v1/scan  — multipart/form-data

@@ -1610,6 +1822,7 @@ _API_DOCS_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 = """ 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; } + } """ @@ -1632,4 +1862,4 @@ _API_DOCS_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))) diff --git a/app/static/index.html b/app/static/index.html index 29f9648..9c9b69b 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -717,6 +717,23 @@ line-height: 1.3; } + .pay-amt { + padding: 7px 12px; + border: 1px solid var(--border, #d0d7e2); + background: #fff; + border-radius: 8px; + font-family: inherit; + font-size: 14px; + cursor: pointer; + color: #92400e; + } + .pay-amt.active { + background: #f59e0b; + border-color: #f59e0b; + color: #fff; + font-weight: 600; + } + @media (max-width: 420px) { .donate-inner { flex-direction: column; } .donate-qr { flex-direction: row; align-items: center; gap: 12px; width: 100%; } @@ -1039,6 +1056,22 @@ + +