From 699c6d9356112b815b9079dfdb5b50a3160ba59e Mon Sep 17 00:00:00 2001 From: berkio_admin_gitea Date: Tue, 7 Jul 2026 15:27:02 +0000 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B8=D1=81=D1=8C=D0=BC=D0=B0=20=D0=BF?= =?UTF-8?q?=D0=BB=D0=B0=D1=82=D0=B5=D0=BB=D1=8C=D1=89=D0=B8=D0=BA=D1=83=20?= =?UTF-8?q?+=20=D0=BF=D1=80=D0=B5=D0=B4=D1=83=D0=BF=D1=80=D0=B5=D0=B6?= =?UTF-8?q?=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BE=D0=B1=20=D0=BE=D0=BA?= =?UTF-8?q?=D0=BE=D0=BD=D1=87=D0=B0=D0=BD=D0=B8=D0=B8=20=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D0=BF=D0=B8=D1=81=D0=BA=D0=B8=20+=20=D0=BF=D0=BE=D0=B4=D1=81?= =?UTF-8?q?=D0=BA=D0=B0=D0=B7=D0=BA=D0=B0=20=D0=BF=D1=80=D0=BE=20=D1=81?= =?UTF-8?q?=D0=BF=D0=B0=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _notify_user_payment: письмо-подтверждение после успешной оплаты (sub/file/api_pro/donate), дедуп WEB_PAYMENTS.user_notified; фискальный чек по-прежнему шлёт ЮKassa - _run_expiry_warnings_once (в цикле автопродления, раз в 6ч): за ≤3 дня до конца подписки без автопродления — сообщение в Telegram (tg:*), иначе письмо на pm_email; один раз на период (WEB_ACCOUNTS.warned_until); pm_email сохраняется при каждой оплате подписки - Модалка входа по почте: подсказка «Письма нет? Проверьте папку Спам» (7 языков) Co-Authored-By: Claude Opus 4.8 --- app/main.py | 135 +++++++++++++++++++++++++++++++++++++++--- app/static/index.html | 10 +++- 2 files changed, 137 insertions(+), 8 deletions(-) diff --git a/app/main.py b/app/main.py index 8d1de7b..18d269d 100644 --- a/app/main.py +++ b/app/main.py @@ -388,13 +388,22 @@ def _db_ensure_auth_table(): con.execute(f"ALTER TABLE WEB_ACCOUNTS ADD COLUMN {_c} {_ddl}") except Exception as _amig: print(f"WEB_ACCOUNTS migrate error: {_amig}") - # Авто-миграция WEB_PAYMENTS: флаг отправленного уведомления (дедуп) + # Авто-миграция WEB_PAYMENTS: флаги отправленных уведомлений (дедуп) try: pcols = {row[1] for row in con.execute("PRAGMA table_info(WEB_PAYMENTS)")} if "notified" not in pcols: con.execute("ALTER TABLE WEB_PAYMENTS ADD COLUMN notified INTEGER DEFAULT 0") + if "user_notified" not in pcols: + con.execute("ALTER TABLE WEB_PAYMENTS ADD COLUMN user_notified INTEGER DEFAULT 0") except Exception as _pmig: print(f"WEB_PAYMENTS migrate error: {_pmig}") + # Авто-миграция WEB_ACCOUNTS: отметка «предупреждён об окончании этого периода» + try: + acols2 = {row[1] for row in con.execute("PRAGMA table_info(WEB_ACCOUNTS)")} + if "warned_until" not in acols2: + con.execute("ALTER TABLE WEB_ACCOUNTS ADD COLUMN warned_until REAL DEFAULT 0") + except Exception as _wmig: + print(f"WEB_ACCOUNTS warned migrate error: {_wmig}") con.commit() except Exception as e: print(f"WEB_AUTH_TOKENS create error: {e}") @@ -1788,19 +1797,20 @@ def _db_set_autorenew_flag(account_id, enable: bool) -> dict: return {"autorenew": False, "has_method": False} -def _tg_send(chat_id, text): +def _tg_send(chat_id, text) -> bool: """Отправка сообщения в Telegram напрямую через Bot API (для уведомлений из веба).""" if not BOT_TOKEN or not chat_id: - return + return False try: data = _urlparse.urlencode({"chat_id": chat_id, "text": text, "disable_web_page_preview": "true"}).encode() req = _urlreq.Request(f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage", data=data, method="POST") with _urlreq.urlopen(req, timeout=15) as resp: - resp.read() + return b'"ok":true' in resp.read() except Exception as e: print(f"tg notify error: {e}") + return False def _notify_owner_payment(payment_id): @@ -1827,6 +1837,60 @@ def _notify_owner_payment(payment_id): _tg_send(PAYMENT_NOTIFY_CHAT_ID, text) +def _notify_user_payment(payment_id, purpose, extra=None): + """Письмо-подтверждение плательщику (не фискальный чек — его шлёт ЮKassa). + Дедуп через WEB_PAYMENTS.user_notified.""" + extra = extra or {} + try: + with sqlite3.connect(DB_PATH, timeout=10) as con: + con.row_factory = sqlite3.Row + cur = con.execute( + "UPDATE WEB_PAYMENTS SET user_notified=1 WHERE payment_id=? AND COALESCE(user_notified,0)=0", + (payment_id,)) + con.commit() + if cur.rowcount != 1: + return + row = con.execute("SELECT amount, email FROM WEB_PAYMENTS WHERE payment_id=?", + (payment_id,)).fetchone() + except Exception: + return + if not row or not (row["email"] or "").strip(): + return + email = row["email"].strip() + amount = int(round(row["amount"] or 0)) + if purpose == "sub": + until = _db_sub_until(extra.get("account_id")) + until_s = datetime.datetime.fromtimestamp(until).strftime("%d.%m.%Y") if until else "" + subject = "pdf2scan: подписка активна" + body = (f"Спасибо за оплату!\n\n" + f"Подписка pdf2scan ({amount} ₽) активна" + (f" до {until_s}" if until_s else "") + ".\n" + f"Что открыто: файлы без ограничений лимита и пакетная обработка на сайте.\n\n" + f"Управление подпиской (продление, автопродление): https://pdf2scan.online — карточка подписки после входа.\n\n" + f"Фискальный чек приходит отдельным письмом от ЮKassa.\n" + f"Вопросы: b3rkut@yandex.ru или @berkio в Telegram.") + elif purpose == "file": + subject = "pdf2scan: оплата обработки файла получена" + body = (f"Спасибо за оплату!\n\n" + f"Оплата обработки файла ({amount} ₽) получена — файл поставлен в очередь.\n" + f"Результат появится там же, где вы загружали файл (сайт или Telegram-бот).\n\n" + f"Фискальный чек приходит отдельным письмом от ЮKassa.\n" + f"Вопросы: b3rkut@yandex.ru или @berkio в Telegram.") + elif purpose == "api_pro": + subject = "pdf2scan: API-тариф Business активен" + body = (f"Спасибо за оплату!\n\n" + f"Тариф Business для API ({amount} ₽, 30 дней) активирован.\n" + f"Документация: https://pdf2scan.online/developers\n\n" + f"Фискальный чек приходит отдельным письмом от ЮKassa.") + else: + subject = "pdf2scan: спасибо за поддержку!" + body = (f"Ваш донат {amount} ₽ получен. Спасибо, что поддерживаете проект!\n\n" + f"Команда pdf2scan.online") + try: + _send_email(email, subject, body) + except Exception as e: + print(f"user receipt email error: {e}") + + def _db_mark_payment_paid(payment_id, purpose, api_key, extra=None, payment_method=None): """extra: metadata платежа (job_id для file, account_id для sub). payment_method: объект payment_method из ЮKassa (для сохранения при автопродлении).""" @@ -1848,6 +1912,15 @@ def _db_mark_payment_paid(payment_id, purpose, api_key, extra=None, payment_meth acc = extra.get("account_id") if acc: _db_activate_sub(acc, SUB_DAYS) + # email плательщика — для предупреждения об окончании подписки + if extra.get("email"): + try: + with sqlite3.connect(DB_PATH, timeout=10) as con: + con.execute("UPDATE WEB_ACCOUNTS SET pm_email=COALESCE(NULLIF(?,''), pm_email) WHERE account_id=?", + (extra.get("email"), acc)) + con.commit() + except Exception: + pass # Автопродление: пользователь согласился и способ оплаты сохранён if str(extra.get("autorenew")) in ("1", "true", "True") and \ payment_method and payment_method.get("saved") and payment_method.get("id"): @@ -1859,8 +1932,12 @@ def _db_mark_payment_paid(payment_id, purpose, api_key, extra=None, payment_meth # Воронка: успешная оплата пейвольных покупок (файл/подписка) if purpose in ("file", "sub"): _bump_funnel("pay_success") - # Уведомление владельцу в Telegram (в отдельном потоке — не блокируем event loop) - threading.Thread(target=_notify_owner_payment, args=(payment_id,), daemon=True).start() + # Уведомления (в отдельном потоке — не блокируем event loop): + # владельцу в Telegram + письмо-подтверждение плательщику + def _notify_all(): + _notify_owner_payment(payment_id) + _notify_user_payment(payment_id, purpose, extra) + threading.Thread(target=_notify_all, daemon=True).start() except Exception as e: print(f"mark paid error: {e}") @@ -2092,15 +2169,59 @@ def _run_autorenew_once(): print(f"autorenew row error: {e}") +def _run_expiry_warnings_once(): + """Предупреждает за ≤3 дня до окончания подписки (тем, у кого НЕ сработает автопродление): + tg: — сообщением в Telegram, иначе письмом на pm_email. Один раз на период (warned_until).""" + now = time.time() + with sqlite3.connect(DB_PATH, timeout=15) as con: + con.row_factory = sqlite3.Row + rows = [dict(r) for r in con.execute( + "SELECT account_id, sub_until, pm_email, autorenew, renew_fail FROM WEB_ACCOUNTS " + "WHERE sub_until > ? AND sub_until <= ? AND COALESCE(warned_until,0) != sub_until", + (now, now + 3 * 86400))] + for r in rows: + # автопродление сработает само — не пугаем человека зря + if r["autorenew"] and (r["renew_fail"] or 0) < 3: + continue + until_s = datetime.datetime.fromtimestamp(r["sub_until"]).strftime("%d.%m.%Y") + text = (f"⏳ Подписка pdf2scan заканчивается {until_s}.\n" + f"Продлить: на сайте https://pdf2scan.online (кнопка «Продлить» в карточке подписки) " + f"или в боте командой /subscribe.") + delivered = False + acc = r["account_id"] or "" + if acc.startswith("tg:"): + delivered = _tg_send(acc.split(":", 1)[1], text) + if not delivered and (r["pm_email"] or "").strip(): + try: + _send_email(r["pm_email"].strip(), "pdf2scan: подписка скоро закончится", text) + delivered = True + except Exception as e: + print(f"expiry mail error {acc}: {e}") + if delivered: + try: + with sqlite3.connect(DB_PATH, timeout=10) as con: + con.execute("UPDATE WEB_ACCOUNTS SET warned_until=? WHERE account_id=?", + (r["sub_until"], acc)) + con.commit() + except Exception: + pass + print(f"expiry warning sent: {acc} (до {until_s})") + + def _autorenew_loop(): """Фоновый цикл: раз в 6ч списывает подписку у аккаунтов с автопродлением, - у которых срок истекает в ближайшие 2 дня. Списание ДО истечения — без разрыва.""" + у которых срок истекает в ближайшие 2 дня. Списание ДО истечения — без разрыва. + Здесь же — предупреждения об окончании подписки (без автопродления).""" time.sleep(120) # дать приложению прогреться while True: try: _run_autorenew_once() except Exception as e: print(f"autorenew loop error: {e}") + try: + _run_expiry_warnings_once() + except Exception as e: + print(f"expiry warn loop error: {e}") time.sleep(6 * 3600) diff --git a/app/static/index.html b/app/static/index.html index 3d83e95..9a40b2c 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -914,7 +914,8 @@ @@ -1253,6 +1254,7 @@ email_ph: 'ваш email', email_send_code: 'Получить код', email_code_sent: 'Код отправлен на почту. Введите его:', + email_check_spam: 'Письма нет? Проверьте папку «Спам»', email_login_btn: 'Войти', email_sending: 'Отправляем код…', email_bad_code: 'Неверный код', @@ -1400,6 +1402,7 @@ email_ph: 'your email', email_send_code: 'Get code', email_code_sent: 'Code sent to your email. Enter it:', + email_check_spam: 'No email? Check your Spam folder', email_login_btn: 'Log in', email_sending: 'Sending code…', email_bad_code: 'Invalid code', @@ -1548,6 +1551,7 @@ email_ph: 'Ihre E-Mail', email_send_code: 'Code erhalten', email_code_sent: 'Code an Ihre E-Mail gesendet. Eingeben:', + email_check_spam: 'Keine E-Mail? Prüfen Sie den Spam-Ordner', email_login_btn: 'Anmelden', email_sending: 'Code wird gesendet…', email_bad_code: 'Ungültiger Code', @@ -1696,6 +1700,7 @@ email_ph: 'votre e-mail', email_send_code: 'Recevoir le code', email_code_sent: 'Code envoyé par e-mail. Saisissez-le :', + email_check_spam: 'Pas de message ? Vérifiez le dossier Spam', email_login_btn: 'Se connecter', email_sending: 'Envoi du code…', email_bad_code: 'Code invalide', @@ -1844,6 +1849,7 @@ email_ph: 'la tua email', email_send_code: 'Ricevi codice', email_code_sent: 'Codice inviato via email. Inseriscilo:', + email_check_spam: 'Nessuna email? Controlla la cartella Spam', email_login_btn: 'Accedi', email_sending: 'Invio codice…', email_bad_code: 'Codice non valido', @@ -1992,6 +1998,7 @@ email_ph: 'tu correo', email_send_code: 'Obtener código', email_code_sent: 'Código enviado a tu correo. Ingrésalo:', + email_check_spam: 'Sin correo? Revisa la carpeta de spam', email_login_btn: 'Entrar', email_sending: 'Enviando código…', email_bad_code: 'Código inválido', @@ -2140,6 +2147,7 @@ email_ph: 'seu e-mail', email_send_code: 'Receber código', email_code_sent: 'Código enviado ao seu e-mail. Digite:', + email_check_spam: 'Sem e-mail? Verifique a pasta de spam', email_login_btn: 'Entrar', email_sending: 'Enviando código…', email_bad_code: 'Código inválido',