Пейволл больших файлов: 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:
43
app/main.py
43
app/main.py
@@ -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,
|
def _finalize_submit(job_id, file_path, original_name, stitching, title,
|
||||||
numeration, flip_side, color, pdfa, quality,
|
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.
|
"""Общий хвост постановки задания: проверка авторизации, запись в БД, _meta.
|
||||||
trusted=True (бизнес-API по ключу) — пропускает пейволл.
|
trusted=True (бизнес-API по ключу) — пропускает пейволл.
|
||||||
|
account_id — аккаунт запроса (из сессии/TG) для проверки подписки.
|
||||||
web_owner_id — TG id владельца для истории «Мои документы» (по умолчанию = user_id)."""
|
web_owner_id — TG id владельца для истории «Мои документы» (по умолчанию = user_id)."""
|
||||||
# Пейволл: ≤50МБ и ≤100стр — бесплатно; больше — нужна активная подписка,
|
# Пейволл: ≤50МБ и ≤100стр — бесплатно; больше — нужна активная подписка,
|
||||||
# иначе задание держим (url_res_f='awaiting_payment') до разовой оплаты 99₽.
|
# иначе задание держим (url_res_f='awaiting_payment') до разовой оплаты 99₽.
|
||||||
|
acc = account_id or _account_id(user_id)
|
||||||
held = False
|
held = False
|
||||||
if not trusted and (size_mb > AUTH_REQUIRED_MB or pages > AUTH_REQUIRED_PAGES):
|
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 # активная подписка — бесплатно
|
pass # активная подписка — бесплатно
|
||||||
elif PAYWALL_ENABLED:
|
elif PAYWALL_ENABLED:
|
||||||
held = True # держим до оплаты 99₽ или подписки
|
held = True # держим до оплаты 99₽ или подписки
|
||||||
@@ -926,6 +929,7 @@ def _build_zip(items) -> str:
|
|||||||
|
|
||||||
@app.post("/api/submit")
|
@app.post("/api/submit")
|
||||||
async def submit(
|
async def submit(
|
||||||
|
request: Request,
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
stitching: str = Form("без сшивки"),
|
stitching: str = Form("без сшивки"),
|
||||||
title: int = Form(0),
|
title: int = Form(0),
|
||||||
@@ -982,7 +986,7 @@ async def submit(
|
|||||||
return _finalize_submit(
|
return _finalize_submit(
|
||||||
job_id, file_path, original_name, stitching, title,
|
job_id, file_path, original_name, stitching, title,
|
||||||
numeration, flip_side, color, pdfa, quality, size_mb, pages, user_id,
|
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")
|
@app.post("/api/submit-chunked")
|
||||||
async def submit_chunked(
|
async def submit_chunked(
|
||||||
|
request: Request,
|
||||||
upload_id: str = Form(...),
|
upload_id: str = Form(...),
|
||||||
filename: str = Form("document.pdf"),
|
filename: str = Form("document.pdf"),
|
||||||
total_size: int = Form(0),
|
total_size: int = Form(0),
|
||||||
@@ -1061,7 +1066,7 @@ async def submit_chunked(
|
|||||||
return _finalize_submit(
|
return _finalize_submit(
|
||||||
job_id, file_path, original_name, stitching, title,
|
job_id, file_path, original_name, stitching, title,
|
||||||
numeration, flip_side, color, pdfa, quality, size_mb, pages, user_id,
|
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:
|
def _account_id(user_id) -> str | None:
|
||||||
"""Внутренний id аккаунта. Пока источник — Telegram; позже yandex:/vk:/email:."""
|
"""Внутренний id аккаунта из Telegram user_id."""
|
||||||
try:
|
try:
|
||||||
uid = int(user_id or 0)
|
uid = int(user_id or 0)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
@@ -1601,6 +1606,17 @@ def _account_id(user_id) -> str | None:
|
|||||||
return f"tg:{uid}" if uid else 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:
|
def _db_sub_active(account_id: str | None) -> bool:
|
||||||
if not account_id:
|
if not account_id:
|
||||||
return False
|
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=?",
|
con.execute("UPDATE WEB_API_KEYS SET plan='business', paid_until=? WHERE api_key=?",
|
||||||
(until, api_key))
|
(until, api_key))
|
||||||
con.commit()
|
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":
|
if purpose == "sub":
|
||||||
acc = extra.get("account_id")
|
acc = extra.get("account_id")
|
||||||
if acc:
|
if acc:
|
||||||
_db_activate_sub(acc, SUB_DAYS)
|
_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:
|
except Exception as e:
|
||||||
print(f"mark paid error: {e}")
|
print(f"mark paid error: {e}")
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/pay/create")
|
@app.post("/api/pay/create")
|
||||||
async def pay_create(payload: dict):
|
async def pay_create(payload: dict, request: Request):
|
||||||
"""Создаёт платёж ЮKassa. body: {amount, purpose, email?, api_key?} → confirmation_url."""
|
"""Создаёт платёж ЮKassa. body: {amount, purpose, email?, api_key?, job_id?, user_id?} → confirmation_url."""
|
||||||
if not YOOKASSA_SHOP_ID or not YOOKASSA_SECRET_KEY:
|
if not YOOKASSA_SHOP_ID or not YOOKASSA_SECRET_KEY:
|
||||||
raise HTTPException(status_code=503, detail="Приём платежей не настроен.")
|
raise HTTPException(status_code=503, detail="Приём платежей не настроен.")
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
@@ -1669,7 +1684,7 @@ async def pay_create(payload: dict):
|
|||||||
email = (payload.get("email") or "").strip()[:128]
|
email = (payload.get("email") or "").strip()[:128]
|
||||||
api_key = (payload.get("api_key") or "").strip()[:80] or None
|
api_key = (payload.get("api_key") or "").strip()[:80] or None
|
||||||
job_id = (payload.get("job_id") or "").strip()[:128] 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":
|
if purpose == "api_pro":
|
||||||
amount = API_PRO_PRICE_RUB
|
amount = API_PRO_PRICE_RUB
|
||||||
|
|||||||
@@ -1011,8 +1011,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Auth banner -->
|
<!-- Auth banner (не используется — заменён пейволлом) -->
|
||||||
<div id="auth-banner">
|
<div id="auth-banner" style="display:none">
|
||||||
<h3 data-i18n="auth_required">🔐 Требуется авторизация</h3>
|
<h3 data-i18n="auth_required">🔐 Требуется авторизация</h3>
|
||||||
<p data-i18n="auth_required_desc">Для файлов больше 100 страниц или 50 МБ необходим вход через Telegram.</p>
|
<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">
|
<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>
|
</button>
|
||||||
</div>
|
</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 -->
|
<!-- Info -->
|
||||||
<div class="info-msg" id="info-msg"></div>
|
<div class="info-msg" id="info-msg"></div>
|
||||||
|
|
||||||
@@ -1240,6 +1254,13 @@
|
|||||||
pay_error: 'Не удалось создать платёж',
|
pay_error: 'Не удалось создать платёж',
|
||||||
pay_thanks: '✅ Спасибо за поддержку! Оплата прошла.',
|
pay_thanks: '✅ Спасибо за поддержку! Оплата прошла.',
|
||||||
pay_canceled: 'Платёж отменён',
|
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_title: 'Как работает pdf2scan',
|
||||||
seo_step1_title: 'Загрузите файл',
|
seo_step1_title: 'Загрузите файл',
|
||||||
seo_step1_desc: 'PDF или Word — перетащите или выберите с устройства',
|
seo_step1_desc: 'PDF или Word — перетащите или выберите с устройства',
|
||||||
@@ -1348,6 +1369,13 @@
|
|||||||
pay_error: 'Failed to create payment',
|
pay_error: 'Failed to create payment',
|
||||||
pay_thanks: '✅ Thank you for your support! Payment received.',
|
pay_thanks: '✅ Thank you for your support! Payment received.',
|
||||||
pay_canceled: 'Payment canceled',
|
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_title: 'How pdf2scan works',
|
||||||
seo_step1_title: 'Upload your file',
|
seo_step1_title: 'Upload your file',
|
||||||
seo_step1_desc: 'PDF or Word — drag & drop or choose from device',
|
seo_step1_desc: 'PDF or Word — drag & drop or choose from device',
|
||||||
@@ -1456,6 +1484,13 @@
|
|||||||
pay_error: 'Zahlung konnte nicht erstellt werden',
|
pay_error: 'Zahlung konnte nicht erstellt werden',
|
||||||
pay_thanks: '✅ Danke für die Unterstützung! Zahlung erhalten.',
|
pay_thanks: '✅ Danke für die Unterstützung! Zahlung erhalten.',
|
||||||
pay_canceled: 'Zahlung storniert',
|
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_title: 'So funktioniert pdf2scan',
|
||||||
seo_step1_title: 'Datei hochladen',
|
seo_step1_title: 'Datei hochladen',
|
||||||
seo_step1_desc: 'PDF oder Word — per Drag & Drop oder vom Gerät',
|
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_error: 'Échec de création du paiement',
|
||||||
pay_thanks: '✅ Merci pour votre soutien ! Paiement reçu.',
|
pay_thanks: '✅ Merci pour votre soutien ! Paiement reçu.',
|
||||||
pay_canceled: 'Paiement annulé',
|
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_title: 'Comment fonctionne pdf2scan',
|
||||||
seo_step1_title: 'Téléchargez votre fichier',
|
seo_step1_title: 'Téléchargez votre fichier',
|
||||||
seo_step1_desc: 'PDF ou Word — glisser-déposer ou choisir depuis l\'appareil',
|
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_error: 'Impossibile creare il pagamento',
|
||||||
pay_thanks: '✅ Grazie per il supporto! Pagamento ricevuto.',
|
pay_thanks: '✅ Grazie per il supporto! Pagamento ricevuto.',
|
||||||
pay_canceled: 'Pagamento annullato',
|
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_title: 'Come funziona pdf2scan',
|
||||||
seo_step1_title: 'Carica il file',
|
seo_step1_title: 'Carica il file',
|
||||||
seo_step1_desc: 'PDF o Word — trascina o scegli dal dispositivo',
|
seo_step1_desc: 'PDF o Word — trascina o scegli dal dispositivo',
|
||||||
@@ -1780,6 +1829,13 @@
|
|||||||
pay_error: 'No se pudo crear el pago',
|
pay_error: 'No se pudo crear el pago',
|
||||||
pay_thanks: '✅ ¡Gracias por tu apoyo! Pago recibido.',
|
pay_thanks: '✅ ¡Gracias por tu apoyo! Pago recibido.',
|
||||||
pay_canceled: 'Pago cancelado',
|
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_title: 'Cómo funciona pdf2scan',
|
||||||
seo_step1_title: 'Sube tu archivo',
|
seo_step1_title: 'Sube tu archivo',
|
||||||
seo_step1_desc: 'PDF o Word — arrastra o elige desde el dispositivo',
|
seo_step1_desc: 'PDF o Word — arrastra o elige desde el dispositivo',
|
||||||
@@ -1888,6 +1944,13 @@
|
|||||||
pay_error: 'Falha ao criar o pagamento',
|
pay_error: 'Falha ao criar o pagamento',
|
||||||
pay_thanks: '✅ Obrigado pelo apoio! Pagamento recebido.',
|
pay_thanks: '✅ Obrigado pelo apoio! Pagamento recebido.',
|
||||||
pay_canceled: 'Pagamento cancelado',
|
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_title: 'Como funciona o pdf2scan',
|
||||||
seo_step1_title: 'Envie seu arquivo',
|
seo_step1_title: 'Envie seu arquivo',
|
||||||
seo_step1_desc: 'PDF ou Word — arraste ou escolha do dispositivo',
|
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; }
|
} 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() {
|
(function handlePayReturn() {
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
if (!params.has('paid')) return;
|
if (!params.has('paid')) return;
|
||||||
|
const paid = params.get('paid');
|
||||||
const pid = localStorage.getItem('pdf2scan_pay_id');
|
const pid = localStorage.getItem('pdf2scan_pay_id');
|
||||||
// чистим URL, чтобы не срабатывало при обновлении
|
const payJob = localStorage.getItem('pdf2scan_pay_job');
|
||||||
try { history.replaceState({}, '', location.pathname); } catch (_) {}
|
try { history.replaceState({}, '', location.pathname); } catch (_) {}
|
||||||
if (!pid) return;
|
if (!pid) return;
|
||||||
let tries = 0;
|
let tries = 0;
|
||||||
@@ -2037,11 +2140,21 @@
|
|||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
if (d.status === 'succeeded') {
|
if (d.status === 'succeeded') {
|
||||||
localStorage.removeItem('pdf2scan_pay_id');
|
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 (_) {}
|
} catch (_) {}
|
||||||
if (tries <= 20) setTimeout(check, 2000);
|
if (tries <= 25) setTimeout(check, 2000);
|
||||||
};
|
};
|
||||||
check();
|
check();
|
||||||
})();
|
})();
|
||||||
@@ -2442,11 +2555,18 @@
|
|||||||
el.innerHTML = `<span class="bs bs-wait">${t('st_queued')}${j.queuePos ? ' · ' + j.queuePos : ''}</span>`;
|
el.innerHTML = `<span class="bs bs-wait">${t('st_queued')}${j.queuePos ? ' · ' + j.queuePos : ''}</span>`;
|
||||||
} else if (j.status === 'uploading') {
|
} else if (j.status === 'uploading') {
|
||||||
el.innerHTML = `<span class="bs bs-wait">${t('st_uploading')}</span>`;
|
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 {
|
} else {
|
||||||
el.innerHTML = `<span class="bs bs-wait">—</span>`;
|
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) {
|
async function handleFile(f) {
|
||||||
selectedFile = f;
|
selectedFile = f;
|
||||||
fileBadge.textContent = f.name;
|
fileBadge.textContent = f.name;
|
||||||
@@ -2477,23 +2597,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderAuthBanner() {
|
function renderAuthBanner() {
|
||||||
|
// Старый баннер авторизации больше не используется — гейт больших файлов
|
||||||
|
// теперь на сервере (пейволл: оплата 99₽ или подписка после отправки).
|
||||||
const banner = document.getElementById('auth-banner');
|
const banner = document.getElementById('auth-banner');
|
||||||
if (authRequired && !tgUser) {
|
if (banner) banner.style.display = 'none';
|
||||||
banner.style.display = 'flex';
|
|
||||||
} else {
|
|
||||||
banner.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateSubmitBtn() {
|
function updateSubmitBtn() {
|
||||||
renderAuthBanner(); // держим баннер и кнопку в одном состоянии (одинаковое условие)
|
renderAuthBanner();
|
||||||
const hasSelection = batchMode ? batchJobs.length > 0 : !!selectedFile;
|
const hasSelection = batchMode ? batchJobs.length > 0 : !!selectedFile;
|
||||||
if (!hasSelection) {
|
if (!hasSelection) {
|
||||||
submitBtn.disabled = true;
|
submitBtn.disabled = true;
|
||||||
submitBtn.textContent = t('btn_select_file');
|
submitBtn.textContent = t('btn_select_file');
|
||||||
} else if (authRequired && !tgUser) {
|
|
||||||
submitBtn.disabled = true;
|
|
||||||
submitBtn.textContent = t('btn_auth_required');
|
|
||||||
} else {
|
} else {
|
||||||
submitBtn.disabled = false;
|
submitBtn.disabled = false;
|
||||||
submitBtn.textContent = batchMode ? t('btn_process_n')(batchJobs.length) : t('btn_process');
|
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);
|
if (tgUser) ff.append('user_id', tgUser.user_id || tgUser.id || 0);
|
||||||
const fr = await fetchRetry('/api/submit-chunked', { method: 'POST', body: ff }, 3);
|
const fr = await fetchRetry('/api/submit-chunked', { method: 'POST', body: ff }, 3);
|
||||||
if (!fr.ok) throw new Error(await _detailOf(fr));
|
if (!fr.ok) throw new Error(await _detailOf(fr));
|
||||||
const data = await fr.json();
|
return await fr.json(); // {job_id, requires_payment?, file_price?, sub_price?}
|
||||||
return data.job_id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitBatch() {
|
async function submitBatch() {
|
||||||
@@ -2619,8 +2733,14 @@
|
|||||||
async function uploadOne(j, s) {
|
async function uploadOne(j, s) {
|
||||||
j.status = 'uploading'; j.error = null; updateRow(j);
|
j.status = 'uploading'; j.error = null; updateRow(j);
|
||||||
try {
|
try {
|
||||||
j.jobId = await uploadFileChunked(j.file, s, null);
|
const data = await uploadFileChunked(j.file, s, null);
|
||||||
j.status = 'queued'; j.error = null; updateRow(j);
|
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) {
|
} catch (e) {
|
||||||
j.status = 'error';
|
j.status = 'error';
|
||||||
j.error = (e && e.message) ? e.message : t('err_network');
|
j.error = (e && e.message) ? e.message : t('err_network');
|
||||||
@@ -2773,6 +2893,7 @@
|
|||||||
const data = JSON.parse(xhr.responseText);
|
const data = JSON.parse(xhr.responseText);
|
||||||
currentJobId = data.job_id;
|
currentJobId = data.job_id;
|
||||||
document.getElementById('progress-speed').textContent = '';
|
document.getElementById('progress-speed').textContent = '';
|
||||||
|
if (data.requires_payment) { showPaywall(data.job_id, data.file_price, data.sub_price); return; }
|
||||||
setUiState('processing');
|
setUiState('processing');
|
||||||
startPolling();
|
startPolling();
|
||||||
if (filePages > DONATE_PAGE_THRESHOLD || fileSizeMb > 10) {
|
if (filePages > DONATE_PAGE_THRESHOLD || fileSizeMb > 10) {
|
||||||
@@ -2809,13 +2930,15 @@
|
|||||||
document.getElementById('progress-status').textContent = t('progress_loading');
|
document.getElementById('progress-status').textContent = t('progress_loading');
|
||||||
document.getElementById('progress-speed').textContent = '';
|
document.getElementById('progress-speed').textContent = '';
|
||||||
try {
|
try {
|
||||||
currentJobId = await uploadFileChunked(selectedFile, collectSettings(), (up, tot) => {
|
const data = await uploadFileChunked(selectedFile, collectSettings(), (up, tot) => {
|
||||||
const pct = Math.round(up / tot * 100);
|
const pct = Math.round(up / tot * 100);
|
||||||
setProgressPct(pct);
|
setProgressPct(pct);
|
||||||
document.getElementById('progress-status').textContent =
|
document.getElementById('progress-status').textContent =
|
||||||
pct < 100 ? t('progress_loading') : t('progress_uploaded');
|
pct < 100 ? t('progress_loading') : t('progress_uploaded');
|
||||||
});
|
});
|
||||||
|
currentJobId = data.job_id;
|
||||||
document.getElementById('progress-speed').textContent = '';
|
document.getElementById('progress-speed').textContent = '';
|
||||||
|
if (data.requires_payment) { showPaywall(data.job_id, data.file_price, data.sub_price); return; }
|
||||||
setUiState('processing');
|
setUiState('processing');
|
||||||
startPolling();
|
startPolling();
|
||||||
if (filePages > DONATE_PAGE_THRESHOLD || fileSizeMb > 10) showDonateCard(true);
|
if (filePages > DONATE_PAGE_THRESHOLD || fileSizeMb > 10) showDonateCard(true);
|
||||||
@@ -2827,7 +2950,7 @@
|
|||||||
function onUploadError(msg) {
|
function onUploadError(msg) {
|
||||||
showError('⚠ ' + msg);
|
showError('⚠ ' + msg);
|
||||||
document.getElementById('progress-card').style.display = 'none';
|
document.getElementById('progress-card').style.display = 'none';
|
||||||
submitBtn.disabled = !selectedFile || (authRequired && !tgUser);
|
submitBtn.disabled = !selectedFile;
|
||||||
submitBtn.textContent = t('btn_process');
|
submitBtn.textContent = t('btn_process');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2881,6 +3004,10 @@
|
|||||||
stopProgressSim();
|
stopProgressSim();
|
||||||
setProgressPct(100);
|
setProgressPct(100);
|
||||||
setTimeout(() => setUiState('done'), 300);
|
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') {
|
} else if (data.status === 'error') {
|
||||||
clearInterval(pollInterval);
|
clearInterval(pollInterval);
|
||||||
stopProgressSim();
|
stopProgressSim();
|
||||||
@@ -2919,7 +3046,7 @@
|
|||||||
submitBtn.textContent = t('btn_process');
|
submitBtn.textContent = t('btn_process');
|
||||||
showDonateCard(filePages > DONATE_PAGE_THRESHOLD || fileSizeMb > 10);
|
showDonateCard(filePages > DONATE_PAGE_THRESHOLD || fileSizeMb > 10);
|
||||||
} else {
|
} else {
|
||||||
submitBtn.disabled = !selectedFile || (authRequired && !tgUser);
|
submitBtn.disabled = !selectedFile;
|
||||||
submitBtn.textContent = selectedFile ? t('btn_process') : t('btn_select_file');
|
submitBtn.textContent = selectedFile ? t('btn_process') : t('btn_select_file');
|
||||||
progressCard.style.display = 'none';
|
progressCard.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user