Письма плательщику + предупреждение об окончании подписки + подсказка про спам

- _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:
berkio_admin_gitea
2026-07-07 15:27:02 +00:00
parent 13404848c7
commit 699c6d9356
2 changed files with 137 additions and 8 deletions

View File

@@ -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)