Приём платежей Ю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:
232
app/main.py
232
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 = """<!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 запросов/мин, для проб. <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> — 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)))
|
||||
|
||||
@@ -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 @@
|
||||
<div class="donate-buttons">
|
||||
<a class="donate-btn donate-btn-sbp" href="https://finance.ozon.ru/apps/sbp/ozonbankpay/019cbe28-6b54-7eb8-8a52-189633e608ea" target="_blank" rel="noopener" data-i18n="donate_sbp">СБП / Ozon Pay</a>
|
||||
</div>
|
||||
<!-- Оплата картой через ЮKassa -->
|
||||
<div class="donate-pay" style="margin-top:14px">
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-bottom:8px">
|
||||
<button type="button" class="pay-amt" onclick="setPayAmt(100,this)">100 ₽</button>
|
||||
<button type="button" class="pay-amt active" onclick="setPayAmt(300,this)">300 ₽</button>
|
||||
<button type="button" class="pay-amt" onclick="setPayAmt(500,this)">500 ₽</button>
|
||||
<input type="number" id="pay-amt-custom" min="10" max="100000" placeholder="₽" oninput="onCustomAmt()"
|
||||
style="width:76px;padding:7px 8px;border:1px solid var(--border,#d0d7e2);border-radius:8px;font-family:inherit;font-size:14px">
|
||||
</div>
|
||||
<input type="email" id="pay-email" data-i18n-ph="pay_email_ph" placeholder="email для чека"
|
||||
style="width:100%;max-width:260px;padding:8px 10px;border:1px solid var(--border,#d0d7e2);border-radius:8px;font-family:inherit;font-size:14px;margin-bottom:8px">
|
||||
<div>
|
||||
<button type="button" id="pay-card-btn" onclick="payDonate()" class="donate-btn" data-i18n="pay_card_btn">💳 Оплатить картой</button>
|
||||
</div>
|
||||
<div id="pay-msg" style="font-size:12px;color:var(--text-secondary);margin-top:6px"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="donate-qr">
|
||||
<img src="https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=https%3A%2F%2Ffinance.ozon.ru%2Fapps%2Fsbp%2Fozonbankpay%2F019cbe28-6b54-7eb8-8a52-189633e608ea" alt="QR donate" loading="lazy">
|
||||
@@ -1182,6 +1215,14 @@
|
||||
donate_desc_heavy: '⚠️ Большие файлы сильно нагружают сервер. Если сервис полезен — поддержите проект донатом.',
|
||||
donate_sbp: 'СБП / Ozon Pay',
|
||||
donate_qr_hint: 'Сканируйте для оплаты',
|
||||
pay_card_btn: '💳 Оплатить картой',
|
||||
pay_email_ph: 'email для чека',
|
||||
pay_need_email: 'Укажите корректный email для чека',
|
||||
pay_bad_amount: 'Некорректная сумма',
|
||||
pay_creating: 'Создаём платёж…',
|
||||
pay_error: 'Не удалось создать платёж',
|
||||
pay_thanks: '✅ Спасибо за поддержку! Оплата прошла.',
|
||||
pay_canceled: 'Платёж отменён',
|
||||
seo_title: 'Как работает pdf2scan',
|
||||
seo_step1_title: 'Загрузите файл',
|
||||
seo_step1_desc: 'PDF или Word — перетащите или выберите с устройства',
|
||||
@@ -1278,6 +1319,14 @@
|
||||
donate_sbp: 'SBP / Ozon Pay',
|
||||
donate_yoo: 'YooMoney',
|
||||
donate_qr_hint: 'Scan to pay',
|
||||
pay_card_btn: '💳 Pay by card',
|
||||
pay_email_ph: 'email for receipt',
|
||||
pay_need_email: 'Enter a valid email for the receipt',
|
||||
pay_bad_amount: 'Invalid amount',
|
||||
pay_creating: 'Creating payment…',
|
||||
pay_error: 'Failed to create payment',
|
||||
pay_thanks: '✅ Thank you for your support! Payment received.',
|
||||
pay_canceled: 'Payment canceled',
|
||||
seo_title: 'How pdf2scan works',
|
||||
seo_step1_title: 'Upload your file',
|
||||
seo_step1_desc: 'PDF or Word — drag & drop or choose from device',
|
||||
@@ -1374,6 +1423,14 @@
|
||||
donate_sbp: 'SBP / Ozon Pay',
|
||||
donate_yoo: 'YooMoney',
|
||||
donate_qr_hint: 'Zum Bezahlen scannen',
|
||||
pay_card_btn: '💳 Mit Karte zahlen',
|
||||
pay_email_ph: 'E-Mail für Beleg',
|
||||
pay_need_email: 'Gültige E-Mail für den Beleg angeben',
|
||||
pay_bad_amount: 'Ungültiger Betrag',
|
||||
pay_creating: 'Zahlung wird erstellt…',
|
||||
pay_error: 'Zahlung konnte nicht erstellt werden',
|
||||
pay_thanks: '✅ Danke für die Unterstützung! Zahlung erhalten.',
|
||||
pay_canceled: 'Zahlung storniert',
|
||||
seo_title: 'So funktioniert pdf2scan',
|
||||
seo_step1_title: 'Datei hochladen',
|
||||
seo_step1_desc: 'PDF oder Word — per Drag & Drop oder vom Gerät',
|
||||
@@ -1470,6 +1527,14 @@
|
||||
donate_sbp: 'SBP / Ozon Pay',
|
||||
donate_yoo: 'YooMoney',
|
||||
donate_qr_hint: 'Scannez pour payer',
|
||||
pay_card_btn: '💳 Payer par carte',
|
||||
pay_email_ph: 'email pour le reçu',
|
||||
pay_need_email: 'Saisissez un email valide pour le reçu',
|
||||
pay_bad_amount: 'Montant invalide',
|
||||
pay_creating: 'Création du paiement…',
|
||||
pay_error: 'Échec de création du paiement',
|
||||
pay_thanks: '✅ Merci pour votre soutien ! Paiement reçu.',
|
||||
pay_canceled: 'Paiement annulé',
|
||||
seo_title: 'Comment fonctionne pdf2scan',
|
||||
seo_step1_title: 'Téléchargez votre fichier',
|
||||
seo_step1_desc: 'PDF ou Word — glisser-déposer ou choisir depuis l\'appareil',
|
||||
@@ -1566,6 +1631,14 @@
|
||||
donate_sbp: 'SBP / Ozon Pay',
|
||||
donate_yoo: 'YooMoney',
|
||||
donate_qr_hint: 'Scansiona per pagare',
|
||||
pay_card_btn: '💳 Paga con carta',
|
||||
pay_email_ph: 'email per ricevuta',
|
||||
pay_need_email: 'Inserisci un email valido per la ricevuta',
|
||||
pay_bad_amount: 'Importo non valido',
|
||||
pay_creating: 'Creazione pagamento…',
|
||||
pay_error: 'Impossibile creare il pagamento',
|
||||
pay_thanks: '✅ Grazie per il supporto! Pagamento ricevuto.',
|
||||
pay_canceled: 'Pagamento annullato',
|
||||
seo_title: 'Come funziona pdf2scan',
|
||||
seo_step1_title: 'Carica il file',
|
||||
seo_step1_desc: 'PDF o Word — trascina o scegli dal dispositivo',
|
||||
@@ -1662,6 +1735,14 @@
|
||||
donate_sbp: 'SBP / Ozon Pay',
|
||||
donate_yoo: 'YooMoney',
|
||||
donate_qr_hint: 'Escanea para pagar',
|
||||
pay_card_btn: '💳 Pagar con tarjeta',
|
||||
pay_email_ph: 'email para recibo',
|
||||
pay_need_email: 'Ingresa un email válido para el recibo',
|
||||
pay_bad_amount: 'Importe inválido',
|
||||
pay_creating: 'Creando pago…',
|
||||
pay_error: 'No se pudo crear el pago',
|
||||
pay_thanks: '✅ ¡Gracias por tu apoyo! Pago recibido.',
|
||||
pay_canceled: 'Pago cancelado',
|
||||
seo_title: 'Cómo funciona pdf2scan',
|
||||
seo_step1_title: 'Sube tu archivo',
|
||||
seo_step1_desc: 'PDF o Word — arrastra o elige desde el dispositivo',
|
||||
@@ -1758,6 +1839,14 @@
|
||||
donate_sbp: 'SBP / Ozon Pay',
|
||||
donate_yoo: 'YooMoney',
|
||||
donate_qr_hint: 'Escaneie para pagar',
|
||||
pay_card_btn: '💳 Pagar com cartão',
|
||||
pay_email_ph: 'email para recibo',
|
||||
pay_need_email: 'Informe um email válido para o recibo',
|
||||
pay_bad_amount: 'Valor inválido',
|
||||
pay_creating: 'Criando pagamento…',
|
||||
pay_error: 'Falha ao criar o pagamento',
|
||||
pay_thanks: '✅ Obrigado pelo apoio! Pagamento recebido.',
|
||||
pay_canceled: 'Pagamento cancelado',
|
||||
seo_title: 'Como funciona o pdf2scan',
|
||||
seo_step1_title: 'Envie seu arquivo',
|
||||
seo_step1_desc: 'PDF ou Word — arraste ou escolha do dispositivo',
|
||||
@@ -1801,6 +1890,10 @@
|
||||
const val = t(key);
|
||||
if (typeof val === 'string') el.textContent = val;
|
||||
});
|
||||
document.querySelectorAll('[data-i18n-ph]').forEach(el => {
|
||||
const val = t(el.getAttribute('data-i18n-ph'));
|
||||
if (typeof val === 'string') el.setAttribute('placeholder', val);
|
||||
});
|
||||
document.title = t('page_title');
|
||||
document.documentElement.lang = currentLang;
|
||||
// Update flag button
|
||||
@@ -1855,6 +1948,62 @@
|
||||
document.getElementById('donate-card').style.display = 'none';
|
||||
}
|
||||
|
||||
// ── Оплата картой (ЮKassa) ────────────────────────────────────────────────
|
||||
let payAmount = 300;
|
||||
function setPayAmt(v, el) {
|
||||
payAmount = v;
|
||||
const c = document.getElementById('pay-amt-custom'); if (c) c.value = '';
|
||||
document.querySelectorAll('.pay-amt').forEach(b => b.classList.remove('active'));
|
||||
if (el) el.classList.add('active');
|
||||
}
|
||||
function onCustomAmt() {
|
||||
const v = parseFloat(document.getElementById('pay-amt-custom').value);
|
||||
if (v > 0) { payAmount = v; document.querySelectorAll('.pay-amt').forEach(b => b.classList.remove('active')); }
|
||||
}
|
||||
async function payDonate() {
|
||||
const email = (document.getElementById('pay-email').value || '').trim();
|
||||
const msg = document.getElementById('pay-msg');
|
||||
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) { msg.textContent = t('pay_need_email'); return; }
|
||||
if (!payAmount || payAmount < 10) { msg.textContent = t('pay_bad_amount'); return; }
|
||||
const btn = document.getElementById('pay-card-btn'); btn.disabled = true; msg.textContent = t('pay_creating');
|
||||
try {
|
||||
const r = await fetch('/api/pay/create', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ amount: payAmount, purpose: 'donate', email })
|
||||
});
|
||||
const 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 || t('pay_error'); btn.disabled = false; }
|
||||
} catch (e) { msg.textContent = t('pay_error'); btn.disabled = false; }
|
||||
}
|
||||
|
||||
// Возврат с ЮKassa: ?paid=... → опрашиваем статус и показываем результат
|
||||
(function handlePayReturn() {
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (!params.has('paid')) return;
|
||||
const pid = localStorage.getItem('pdf2scan_pay_id');
|
||||
// чистим URL, чтобы не срабатывало при обновлении
|
||||
try { history.replaceState({}, '', location.pathname); } catch (_) {}
|
||||
if (!pid) return;
|
||||
let tries = 0;
|
||||
const check = async () => {
|
||||
tries++;
|
||||
try {
|
||||
const r = await fetch('/api/pay/status/' + encodeURIComponent(pid));
|
||||
const d = await r.json();
|
||||
if (d.status === 'succeeded') {
|
||||
localStorage.removeItem('pdf2scan_pay_id');
|
||||
showInfo(t('pay_thanks')); return;
|
||||
}
|
||||
if (d.status === 'canceled') { localStorage.removeItem('pdf2scan_pay_id'); showError(t('pay_canceled')); return; }
|
||||
} catch (_) {}
|
||||
if (tries <= 20) setTimeout(check, 2000);
|
||||
};
|
||||
check();
|
||||
})();
|
||||
|
||||
// ── App state ─────────────────────────────────────────────────────────────
|
||||
const TG_AUTH_KEY = 'pdf2scan_tg_auth';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user