Пейволл больших файлов: 99₽/файл или подписка 299₽/мес (Фаза 1)

- _finalize_submit держит большой файл (awaiting_payment) без активной подписки;
  account из cookie-сессии (Yandex/VK) или tg user_id
- Оплата purpose=file (снимает задание с холда) / sub (+30 дней, снимает холд)
- Фронт: карточка пейволла (99₽/подписка), обработка возврата, single+batch
- Старый auth-баннер больших файлов заменён пейволлом
- Включается флагом PAYWALL_ENABLED (в .env)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
root
2026-07-03 08:45:27 +00:00
parent e5c4d3c949
commit 836784e56f
2 changed files with 179 additions and 37 deletions

View File

@@ -847,15 +847,18 @@ def _write_chunk_at(path: str, data: bytes, offset: int) -> None:
def _finalize_submit(job_id, file_path, original_name, stitching, title,
numeration, flip_side, color, pdfa, quality,
size_mb, pages, user_id, trusted=False, ocr=0, web_owner_id=None):
size_mb, pages, user_id, trusted=False, ocr=0, web_owner_id=None,
account_id=None):
"""Общий хвост постановки задания: проверка авторизации, запись в БД, _meta.
trusted=True (бизнес-API по ключу) — пропускает пейволл.
account_id — аккаунт запроса (из сессии/TG) для проверки подписки.
web_owner_id — TG id владельца для истории «Мои документы» (по умолчанию = user_id)."""
# Пейволл: ≤50МБ и ≤100стр — бесплатно; больше — нужна активная подписка,
# иначе задание держим (url_res_f='awaiting_payment') до разовой оплаты 99₽.
acc = account_id or _account_id(user_id)
held = False
if not trusted and (size_mb > AUTH_REQUIRED_MB or pages > AUTH_REQUIRED_PAGES):
if _db_sub_active(_account_id(user_id)):
if _db_sub_active(acc):
pass # активная подписка — бесплатно
elif PAYWALL_ENABLED:
held = True # держим до оплаты 99₽ или подписки
@@ -926,6 +929,7 @@ def _build_zip(items) -> str:
@app.post("/api/submit")
async def submit(
request: Request,
file: UploadFile = File(...),
stitching: str = Form("без сшивки"),
title: int = Form(0),
@@ -982,7 +986,7 @@ async def submit(
return _finalize_submit(
job_id, file_path, original_name, stitching, title,
numeration, flip_side, color, pdfa, quality, size_mb, pages, user_id,
ocr=ocr,
ocr=ocr, account_id=_resolve_account(request, user_id),
)
@@ -1009,6 +1013,7 @@ async def upload_chunk(
@app.post("/api/submit-chunked")
async def submit_chunked(
request: Request,
upload_id: str = Form(...),
filename: str = Form("document.pdf"),
total_size: int = Form(0),
@@ -1061,7 +1066,7 @@ async def submit_chunked(
return _finalize_submit(
job_id, file_path, original_name, stitching, title,
numeration, flip_side, color, pdfa, quality, size_mb, pages, user_id,
ocr=ocr,
ocr=ocr, account_id=_resolve_account(request, user_id),
)
@@ -1593,7 +1598,7 @@ def _db_record_payment(payment_id, amount, currency, status, purpose, api_key, e
def _account_id(user_id) -> str | None:
"""Внутренний id аккаунта. Пока источник — Telegram; позже yandex:/vk:/email:."""
"""Внутренний id аккаунта из Telegram user_id."""
try:
uid = int(user_id or 0)
except (TypeError, ValueError):
@@ -1601,6 +1606,17 @@ def _account_id(user_id) -> str | None:
return f"tg:{uid}" if uid else None
def _resolve_account(request, user_id=0) -> str | None:
"""Аккаунт запроса: сначала cookie-сессия (Yandex/VK), иначе tg:<user_id>."""
try:
sess = _db_get_session(request.cookies.get(SESSION_COOKIE)) if request else None
except Exception:
sess = None
if sess and sess.get("account_id"):
return sess["account_id"]
return _account_id(user_id)
def _db_sub_active(account_id: str | None) -> bool:
if not account_id:
return False
@@ -1644,23 +1660,22 @@ def _db_mark_payment_paid(payment_id, purpose, api_key, extra=None):
con.execute("UPDATE WEB_API_KEYS SET plan='business', paid_until=? WHERE api_key=?",
(until, api_key))
con.commit()
# Разовая оплата файла → снимаем задание с холда (в очередь бота)
if purpose == "file":
job_id = extra.get("job_id")
if job_id and _db_get_url_res_f(job_id) == "awaiting_payment":
_db_set_url_res_f(job_id, "-")
# Подписка → продлеваем аккаунт + освобождаем его задания на холде
# Подписка → продлеваем аккаунт
if purpose == "sub":
acc = extra.get("account_id")
if acc:
_db_activate_sub(acc, SUB_DAYS)
# И file, и sub: если платёж привязан к заданию — снимаем его с холда
job_id = extra.get("job_id")
if job_id and _db_get_url_res_f(job_id) == "awaiting_payment":
_db_set_url_res_f(job_id, "-")
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."""
async def pay_create(payload: dict, request: Request):
"""Создаёт платёж ЮKassa. body: {amount, purpose, email?, api_key?, job_id?, user_id?} → confirmation_url."""
if not YOOKASSA_SHOP_ID or not YOOKASSA_SECRET_KEY:
raise HTTPException(status_code=503, detail="Приём платежей не настроен.")
if not isinstance(payload, dict):
@@ -1669,7 +1684,7 @@ async def pay_create(payload: dict):
email = (payload.get("email") or "").strip()[:128]
api_key = (payload.get("api_key") or "").strip()[:80] or None
job_id = (payload.get("job_id") or "").strip()[:128] or None
account_id = _account_id(payload.get("user_id"))
account_id = _resolve_account(request, payload.get("user_id"))
if purpose == "api_pro":
amount = API_PRO_PRICE_RUB

View File

@@ -1011,8 +1011,8 @@
</div>
</div>
<!-- Auth banner -->
<div id="auth-banner">
<!-- Auth banner (не используется — заменён пейволлом) -->
<div id="auth-banner" style="display:none">
<h3 data-i18n="auth_required">🔐 Требуется авторизация</h3>
<p data-i18n="auth_required_desc">Для файлов больше 100 страниц или 50 МБ необходим вход через Telegram.</p>
<button onclick="startTgAuth()" style="padding:10px 24px;background:white;color:#007AFF;border:none;border-radius:10px;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit;" data-i18n="login_tg">
@@ -1020,6 +1020,20 @@
</button>
</div>
<!-- Paywall: большой файл (>50 МБ / >100 стр.) -->
<div id="paywall-card" style="display:none;background:#fff;border:1px solid var(--border,#e2e8f0);border-radius:var(--radius);box-shadow:var(--shadow-sm);padding:18px 20px;margin-bottom:12px;text-align:center;">
<div style="font-size:32px;margin-bottom:4px">💳</div>
<h3 style="margin:0 0 6px;font-size:18px" data-i18n="paywall_title">Файл больше бесплатного лимита</h3>
<p style="margin:0 0 14px;font-size:14px;color:var(--text-secondary)" data-i18n="paywall_desc">Бесплатно — до 50 МБ и 100 страниц. Для файлов больше — разовая оплата или подписка.</p>
<input type="email" id="paywall-email" data-i18n-ph="pay_email_ph" placeholder="email для чека" style="width:100%;max-width:280px;padding:9px 12px;border:1px solid var(--border,#d0d7e2);border-radius:8px;font-size:14px;margin-bottom:12px;font-family:inherit">
<div style="display:flex;gap:10px;flex-wrap:wrap;justify-content:center">
<button id="paywall-file-btn" onclick="payForFile()" style="flex:1;min-width:150px;padding:12px;background:var(--blue,#007AFF);color:#fff;border:none;border-radius:10px;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit"><span data-i18n="paywall_pay_file">Оплатить файл</span> · <span id="paywall-file-price">99</span></button>
<button id="paywall-sub-btn" onclick="payForSub()" style="flex:1;min-width:150px;padding:12px;background:#10b981;color:#fff;border:none;border-radius:10px;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit"><span data-i18n="paywall_pay_sub">Подписка</span> · <span id="paywall-sub-price">299</span> ₽/<span data-i18n="paywall_month">мес</span></button>
</div>
<div id="paywall-msg" style="font-size:12px;color:var(--text-secondary);margin-top:10px"></div>
<button onclick="resetApp()" style="margin-top:8px;background:none;border:none;color:var(--text-secondary);font-size:13px;cursor:pointer;text-decoration:underline;font-family:inherit" data-i18n="paywall_cancel">Отмена</button>
</div>
<!-- Info -->
<div class="info-msg" id="info-msg"></div>
@@ -1240,6 +1254,13 @@
pay_error: 'Не удалось создать платёж',
pay_thanks: '✅ Спасибо за поддержку! Оплата прошла.',
pay_canceled: 'Платёж отменён',
paywall_title: 'Файл больше бесплатного лимита',
paywall_desc: 'Бесплатно — до 50 МБ и 100 страниц. Для файлов больше — разовая оплата или подписка.',
paywall_pay_file: 'Оплатить файл',
paywall_pay_sub: 'Подписка',
paywall_month: 'мес',
paywall_cancel: 'Отмена',
paywall_need_login: 'Для подписки войдите в аккаунт',
seo_title: 'Как работает pdf2scan',
seo_step1_title: 'Загрузите файл',
seo_step1_desc: 'PDF или Word — перетащите или выберите с устройства',
@@ -1348,6 +1369,13 @@
pay_error: 'Failed to create payment',
pay_thanks: '✅ Thank you for your support! Payment received.',
pay_canceled: 'Payment canceled',
paywall_title: 'File exceeds the free limit',
paywall_desc: 'Free up to 50 MB and 100 pages. Larger files — one-time payment or subscription.',
paywall_pay_file: 'Pay for file',
paywall_pay_sub: 'Subscription',
paywall_month: 'mo',
paywall_cancel: 'Cancel',
paywall_need_login: 'Log in to subscribe',
seo_title: 'How pdf2scan works',
seo_step1_title: 'Upload your file',
seo_step1_desc: 'PDF or Word — drag & drop or choose from device',
@@ -1456,6 +1484,13 @@
pay_error: 'Zahlung konnte nicht erstellt werden',
pay_thanks: '✅ Danke für die Unterstützung! Zahlung erhalten.',
pay_canceled: 'Zahlung storniert',
paywall_title: 'Datei über dem Gratis-Limit',
paywall_desc: 'Kostenlos bis 50 MB und 100 Seiten. Größere Dateien — Einmalzahlung oder Abo.',
paywall_pay_file: 'Datei bezahlen',
paywall_pay_sub: 'Abo',
paywall_month: 'Mon',
paywall_cancel: 'Abbrechen',
paywall_need_login: 'Zum Abonnieren anmelden',
seo_title: 'So funktioniert pdf2scan',
seo_step1_title: 'Datei hochladen',
seo_step1_desc: 'PDF oder Word — per Drag & Drop oder vom Gerät',
@@ -1564,6 +1599,13 @@
pay_error: 'Échec de création du paiement',
pay_thanks: '✅ Merci pour votre soutien ! Paiement reçu.',
pay_canceled: 'Paiement annulé',
paywall_title: 'Fichier au-delà de la limite gratuite',
paywall_desc: 'Gratuit : 50 Mo et 100 pages max. Au-delà — paiement unique ou abonnement.',
paywall_pay_file: 'Payer le fichier',
paywall_pay_sub: 'Abonnement',
paywall_month: 'mois',
paywall_cancel: 'Annuler',
paywall_need_login: 'Connectez-vous pour vous abonner',
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',
@@ -1672,6 +1714,13 @@
pay_error: 'Impossibile creare il pagamento',
pay_thanks: '✅ Grazie per il supporto! Pagamento ricevuto.',
pay_canceled: 'Pagamento annullato',
paywall_title: 'File oltre il limite gratuito',
paywall_desc: 'Gratis fino a 50 MB e 100 pagine. File più grandi — pagamento singolo o abbonamento.',
paywall_pay_file: 'Paga file',
paywall_pay_sub: 'Abbonamento',
paywall_month: 'mese',
paywall_cancel: 'Annulla',
paywall_need_login: 'Accedi per abbonarti',
seo_title: 'Come funziona pdf2scan',
seo_step1_title: 'Carica il file',
seo_step1_desc: 'PDF o Word — trascina o scegli dal dispositivo',
@@ -1780,6 +1829,13 @@
pay_error: 'No se pudo crear el pago',
pay_thanks: '✅ ¡Gracias por tu apoyo! Pago recibido.',
pay_canceled: 'Pago cancelado',
paywall_title: 'Archivo supera el límite gratis',
paywall_desc: 'Gratis hasta 50 MB y 100 páginas. Archivos mayores — pago único o suscripción.',
paywall_pay_file: 'Pagar archivo',
paywall_pay_sub: 'Suscripción',
paywall_month: 'mes',
paywall_cancel: 'Cancelar',
paywall_need_login: 'Inicia sesión para suscribirte',
seo_title: 'Cómo funciona pdf2scan',
seo_step1_title: 'Sube tu archivo',
seo_step1_desc: 'PDF o Word — arrastra o elige desde el dispositivo',
@@ -1888,6 +1944,13 @@
pay_error: 'Falha ao criar o pagamento',
pay_thanks: '✅ Obrigado pelo apoio! Pagamento recebido.',
pay_canceled: 'Pagamento cancelado',
paywall_title: 'Arquivo acima do limite grátis',
paywall_desc: 'Grátis até 50 MB e 100 páginas. Arquivos maiores — pagamento único ou assinatura.',
paywall_pay_file: 'Pagar arquivo',
paywall_pay_sub: 'Assinatura',
paywall_month: 'mês',
paywall_cancel: 'Cancelar',
paywall_need_login: 'Entre para assinar',
seo_title: 'Como funciona o pdf2scan',
seo_step1_title: 'Envie seu arquivo',
seo_step1_desc: 'PDF ou Word — arraste ou escolha do dispositivo',
@@ -2021,12 +2084,52 @@
} catch (e) { msg.textContent = t('pay_error'); btn.disabled = false; }
}
// Возврат с ЮKassa: ?paid=... → опрашиваем статус и показываем результат
// ── Пейволл больших файлов ────────────────────────────────────────────────
let paywallJob = null, paywallFilePrice = 99, paywallSubPrice = 299;
function showPaywall(jobId, filePrice, subPrice) {
paywallJob = jobId;
paywallFilePrice = filePrice || 99;
paywallSubPrice = subPrice || 299;
document.getElementById('paywall-file-price').textContent = paywallFilePrice;
document.getElementById('paywall-sub-price').textContent = paywallSubPrice;
const emailEl = document.getElementById('paywall-email');
if (sessionUser && sessionUser.email && !emailEl.value) emailEl.value = sessionUser.email;
document.getElementById('progress-card').style.display = 'none';
document.getElementById('result-card').style.display = 'none';
submitBtn.style.display = 'none';
document.getElementById('paywall-card').style.display = 'block';
}
async function _payViaKassa(purpose) {
const email = (document.getElementById('paywall-email').value || '').trim();
const msg = document.getElementById('paywall-msg');
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) { msg.textContent = t('pay_need_email'); return; }
const body = { purpose, job_id: paywallJob, email };
if (tgUser) body.user_id = tgUser.user_id || tgUser.id || 0;
const fb = document.getElementById('paywall-file-btn'), sb = document.getElementById('paywall-sub-btn');
fb.disabled = true; sb.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(body) });
const d = await r.json();
if (d.confirmation_url) {
localStorage.setItem('pdf2scan_pay_id', d.payment_id);
localStorage.setItem('pdf2scan_pay_job', paywallJob || '');
location.href = d.confirmation_url;
} else { msg.textContent = d.detail || t('pay_error'); fb.disabled = false; sb.disabled = false; }
} catch (e) { msg.textContent = t('pay_error'); fb.disabled = false; sb.disabled = false; }
}
function payForFile() { _payViaKassa('file'); }
function payForSub() {
if (!tgUser && !sessionUser) { document.getElementById('paywall-msg').textContent = t('paywall_need_login'); return; }
_payViaKassa('sub');
}
// Возврат с ЮKassa: ?paid=... → опрашиваем статус, для file/sub запускаем обработку
(function handlePayReturn() {
const params = new URLSearchParams(location.search);
if (!params.has('paid')) return;
const paid = params.get('paid');
const pid = localStorage.getItem('pdf2scan_pay_id');
// чистим URL, чтобы не срабатывало при обновлении
const payJob = localStorage.getItem('pdf2scan_pay_job');
try { history.replaceState({}, '', location.pathname); } catch (_) {}
if (!pid) return;
let tries = 0;
@@ -2037,11 +2140,21 @@
const d = await r.json();
if (d.status === 'succeeded') {
localStorage.removeItem('pdf2scan_pay_id');
showInfo(t('pay_thanks')); return;
localStorage.removeItem('pdf2scan_pay_job');
if ((paid === 'file' || paid === 'sub') && payJob) {
const pc = document.getElementById('paywall-card'); if (pc) pc.style.display = 'none';
currentJobId = payJob; batchMode = false;
setUiState('processing'); startPolling();
}
showInfo(t('pay_thanks'));
return;
}
if (d.status === 'canceled') {
localStorage.removeItem('pdf2scan_pay_id'); localStorage.removeItem('pdf2scan_pay_job');
showError(t('pay_canceled')); return;
}
if (d.status === 'canceled') { localStorage.removeItem('pdf2scan_pay_id'); showError(t('pay_canceled')); return; }
} catch (_) {}
if (tries <= 20) setTimeout(check, 2000);
if (tries <= 25) setTimeout(check, 2000);
};
check();
})();
@@ -2442,11 +2555,18 @@
el.innerHTML = `<span class="bs bs-wait">${t('st_queued')}${j.queuePos ? ' · ' + j.queuePos : ''}</span>`;
} else if (j.status === 'uploading') {
el.innerHTML = `<span class="bs bs-wait">${t('st_uploading')}</span>`;
} else if (j.status === 'awaiting_payment') {
el.innerHTML = `<a class="bs bs-err" style="cursor:pointer;text-decoration:none" onclick="payBatchRow(${j.idx})">💳 ${(j.filePrice||99)} ₽</a>`;
} else {
el.innerHTML = `<span class="bs bs-wait">—</span>`;
}
}
function payBatchRow(idx) {
const j = batchJobs.find(x => x.idx === idx);
if (j && j.jobId) showPaywall(j.jobId, j.filePrice, j.subPrice);
}
async function handleFile(f) {
selectedFile = f;
fileBadge.textContent = f.name;
@@ -2477,23 +2597,18 @@
}
function renderAuthBanner() {
// Старый баннер авторизации больше не используется — гейт больших файлов
// теперь на сервере (пейволл: оплата 99₽ или подписка после отправки).
const banner = document.getElementById('auth-banner');
if (authRequired && !tgUser) {
banner.style.display = 'flex';
} else {
banner.style.display = 'none';
}
if (banner) banner.style.display = 'none';
}
function updateSubmitBtn() {
renderAuthBanner(); // держим баннер и кнопку в одном состоянии (одинаковое условие)
renderAuthBanner();
const hasSelection = batchMode ? batchJobs.length > 0 : !!selectedFile;
if (!hasSelection) {
submitBtn.disabled = true;
submitBtn.textContent = t('btn_select_file');
} else if (authRequired && !tgUser) {
submitBtn.disabled = true;
submitBtn.textContent = t('btn_auth_required');
} else {
submitBtn.disabled = false;
submitBtn.textContent = batchMode ? t('btn_process_n')(batchJobs.length) : t('btn_process');
@@ -2581,8 +2696,7 @@
if (tgUser) ff.append('user_id', tgUser.user_id || tgUser.id || 0);
const fr = await fetchRetry('/api/submit-chunked', { method: 'POST', body: ff }, 3);
if (!fr.ok) throw new Error(await _detailOf(fr));
const data = await fr.json();
return data.job_id;
return await fr.json(); // {job_id, requires_payment?, file_price?, sub_price?}
}
async function submitBatch() {
@@ -2619,8 +2733,14 @@
async function uploadOne(j, s) {
j.status = 'uploading'; j.error = null; updateRow(j);
try {
j.jobId = await uploadFileChunked(j.file, s, null);
j.status = 'queued'; j.error = null; updateRow(j);
const data = await uploadFileChunked(j.file, s, null);
j.jobId = data.job_id;
if (data.requires_payment) {
j.status = 'awaiting_payment'; j.filePrice = data.file_price; j.subPrice = data.sub_price;
} else {
j.status = 'queued';
}
j.error = null; updateRow(j);
} catch (e) {
j.status = 'error';
j.error = (e && e.message) ? e.message : t('err_network');
@@ -2773,6 +2893,7 @@
const data = JSON.parse(xhr.responseText);
currentJobId = data.job_id;
document.getElementById('progress-speed').textContent = '';
if (data.requires_payment) { showPaywall(data.job_id, data.file_price, data.sub_price); return; }
setUiState('processing');
startPolling();
if (filePages > DONATE_PAGE_THRESHOLD || fileSizeMb > 10) {
@@ -2809,13 +2930,15 @@
document.getElementById('progress-status').textContent = t('progress_loading');
document.getElementById('progress-speed').textContent = '';
try {
currentJobId = await uploadFileChunked(selectedFile, collectSettings(), (up, tot) => {
const data = await uploadFileChunked(selectedFile, collectSettings(), (up, tot) => {
const pct = Math.round(up / tot * 100);
setProgressPct(pct);
document.getElementById('progress-status').textContent =
pct < 100 ? t('progress_loading') : t('progress_uploaded');
});
currentJobId = data.job_id;
document.getElementById('progress-speed').textContent = '';
if (data.requires_payment) { showPaywall(data.job_id, data.file_price, data.sub_price); return; }
setUiState('processing');
startPolling();
if (filePages > DONATE_PAGE_THRESHOLD || fileSizeMb > 10) showDonateCard(true);
@@ -2827,7 +2950,7 @@
function onUploadError(msg) {
showError('⚠ ' + msg);
document.getElementById('progress-card').style.display = 'none';
submitBtn.disabled = !selectedFile || (authRequired && !tgUser);
submitBtn.disabled = !selectedFile;
submitBtn.textContent = t('btn_process');
}
@@ -2881,6 +3004,10 @@
stopProgressSim();
setProgressPct(100);
setTimeout(() => setUiState('done'), 300);
} else if (data.status === 'awaiting_payment') {
clearInterval(pollInterval);
stopProgressSim();
showPaywall(currentJobId, data.file_price, data.sub_price);
} else if (data.status === 'error') {
clearInterval(pollInterval);
stopProgressSim();
@@ -2919,7 +3046,7 @@
submitBtn.textContent = t('btn_process');
showDonateCard(filePages > DONATE_PAGE_THRESHOLD || fileSizeMb > 10);
} else {
submitBtn.disabled = !selectedFile || (authRequired && !tgUser);
submitBtn.disabled = !selectedFile;
submitBtn.textContent = selectedFile ? t('btn_process') : t('btn_select_file');
progressCard.style.display = 'none';
}