Приём платежей Ю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

@@ -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';