Письма плательщику + предупреждение об окончании подписки + подсказка про спам
- _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 <noreply@anthropic.com>
This commit is contained in:
135
app/main.py
135
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:<id> — сообщением в 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)
|
||||
|
||||
|
||||
|
||||
@@ -914,7 +914,8 @@
|
||||
<button id="email-send-btn" onclick="emailSendCode()" style="width:100%;padding:11px;background:var(--blue,#007AFF);color:#fff;border:none;border-radius:10px;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit" data-i18n="email_send_code">Получить код</button>
|
||||
</div>
|
||||
<div id="email-step-2" style="display:none">
|
||||
<p style="font-size:13px;color:var(--text-secondary);margin:0 0 10px" data-i18n="email_code_sent">Код отправлен на почту. Введите его:</p>
|
||||
<p style="font-size:13px;color:var(--text-secondary);margin:0 0 6px" data-i18n="email_code_sent">Код отправлен на почту. Введите его:</p>
|
||||
<p style="font-size:12px;color:var(--text-secondary);margin:0 0 10px;opacity:.85">💡 <span data-i18n="email_check_spam">Письма нет? Проверьте папку «Спам»</span></p>
|
||||
<input type="text" id="email-code-input" inputmode="numeric" maxlength="6" placeholder="000000" style="width:100%;padding:10px 12px;border:1px solid #d0d7e2;border-radius:8px;font-size:20px;letter-spacing:6px;text-align:center;font-family:inherit;margin-bottom:10px">
|
||||
<button id="email-verify-btn" onclick="emailVerifyCode()" style="width:100%;padding:11px;background:#10b981;color:#fff;border:none;border-radius:10px;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit" data-i18n="email_login_btn">Войти</button>
|
||||
</div>
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user