fix: improve subscription email renewal flow

This commit is contained in:
3252a8
2026-05-30 00:23:53 +03:00
parent 49781af921
commit 09be82aad5
14 changed files with 352 additions and 38 deletions
+2 -3
View File
@@ -163,9 +163,7 @@ def _legacy_referral_bonus_periods(settings: Settings) -> List[int]:
return sorted(int(months) for months in settings.subscription_options)
def _serialize_tariff_period_referral_bonus_details(
tariff: Any, lang: str
) -> List[Dict[str, Any]]:
def _serialize_tariff_period_referral_bonus_details(tariff: Any, lang: str) -> List[Dict[str, Any]]:
details: List[Dict[str, Any]] = []
for months in sorted(int(month) for month in tariff.enabled_periods):
inviter_days = tariff.referral_inviter_bonus_days(months)
@@ -460,6 +458,7 @@ def _serialize_plans(
for tariff in tariffs_config.enabled_tariffs:
common = {
"tariff_key": tariff.key,
"is_default_tariff": tariff.key == tariffs_config.default_tariff,
"tariff_name": tariff.name(lang),
"billing_model": tariff.billing_model,
"description": tariff.description(lang),
+7 -1
View File
@@ -534,6 +534,7 @@ def render_subscription_lifecycle_notification(
message_text: str,
end_date_text: str,
dashboard_url: Optional[str],
mirrored_from_telegram: bool = False,
days_left: Optional[int] = None,
hours_before: Optional[int] = None,
i18n: Optional[JsonI18n] = None,
@@ -551,7 +552,12 @@ def render_subscription_lifecycle_notification(
days_left=days_left,
hours_before=hours_before,
)
intro = _t_text(i18n, lang, "email_subscription_lifecycle_intro")
intro_key = (
"email_subscription_lifecycle_intro_mirrored"
if mirrored_from_telegram
else "email_subscription_lifecycle_intro_direct"
)
intro = _t_text(i18n, lang, intro_key)
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
cta_label = _t_text(i18n, lang, "email_subscription_lifecycle_cta")
@@ -2,6 +2,7 @@ import logging
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Optional
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
@@ -67,20 +68,30 @@ class SubscriptionLifecycleNotificationService:
resolved_user = user or getattr(sub, "user", None)
lang = getattr(resolved_user, "language_code", None) or self.settings.DEFAULT_LANGUAGE
user_id = int(getattr(sub, "user_id", 0) or 0)
user_name = getattr(resolved_user, "first_name", None) or f"User {user_id}"
final_end_date_text = end_date_text
if final_end_date_text is None:
end_date = self._as_utc(getattr(sub, "end_date", None))
final_end_date_text = end_date.strftime("%Y-%m-%d") if end_date else ""
kwargs = {"user_name": user_name, "end_date": final_end_date_text}
recipient_email = self._email_recipient(resolved_user)
telegram_user_name = self._telegram_display_name(resolved_user, user_id)
email_user_name = self._email_display_name(
resolved_user,
recipient_email=recipient_email,
fallback=telegram_user_name,
)
kwargs = {"user_name": telegram_user_name, "end_date": final_end_date_text}
if stage.hours_before is not None:
kwargs["hours"] = stage.hours_before
message_text = self.i18n.gettext(lang, stage.message_key, **kwargs)
email_kwargs = {**kwargs, "user_name": email_user_name}
email_message_text = self.i18n.gettext(lang, stage.message_key, **email_kwargs)
final_extra_text = str(extra_text or "").strip()
if final_extra_text:
message_text = f"{message_text}\n\n{final_extra_text}"
email_message_text = f"{email_message_text}\n\n{final_extra_text}"
telegram_sent = await self._send_telegram(
session,
@@ -98,8 +109,10 @@ class SubscriptionLifecycleNotificationService:
stage,
resolved_user,
lang=lang,
message_text=message_text,
message_text=email_message_text,
end_date_text=final_end_date_text,
recipient=recipient_email,
telegram_sent=telegram_sent,
sent_at=sent_at,
)
return SubscriptionNotificationDelivery(
@@ -180,13 +193,14 @@ class SubscriptionLifecycleNotificationService:
lang: str,
message_text: str,
end_date_text: str,
recipient: str,
telegram_sent: bool,
sent_at: datetime,
) -> bool:
if not getattr(self.settings, "SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED", True):
return False
if not getattr(self.settings, "email_auth_configured", False):
return False
recipient = str(getattr(user, "email", "") or "").strip() if user else ""
if not recipient:
return False
if await self._already_sent(session, sub.subscription_id, stage.key, "email"):
@@ -199,7 +213,8 @@ class SubscriptionLifecycleNotificationService:
notification_key=stage.key,
message_text=message_text,
end_date_text=end_date_text,
dashboard_url=(self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() or None,
dashboard_url=self._renewal_dashboard_url(recipient, sub),
mirrored_from_telegram=telegram_sent,
days_left=stage.days_left,
hours_before=stage.hours_before,
i18n=self.i18n,
@@ -249,6 +264,64 @@ class SubscriptionLifecycleNotificationService:
def _channel_key(stage_key: str, channel: str) -> str:
return f"{stage_key}:{channel}"
@staticmethod
def _email_recipient(user: Optional[User]) -> str:
return str(getattr(user, "email", "") or "").strip().lower() if user else ""
@staticmethod
def _telegram_display_name(user: Optional[User], fallback_user_id: int) -> str:
return str(getattr(user, "first_name", "") or "").strip() or f"User {fallback_user_id}"
@staticmethod
def _email_display_name(
user: Optional[User],
*,
recipient_email: str,
fallback: str,
) -> str:
return str(getattr(user, "first_name", "") or "").strip() or recipient_email or fallback
def _renewal_dashboard_url(self, recipient_email: str, sub: Subscription) -> Optional[str]:
base_url = (self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip()
if not base_url:
return None
parsed = urlsplit(base_url)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
return None
query = dict(parse_qsl(parsed.query, keep_blank_values=True))
query.update(
{
"login": "email_code",
"login_email": recipient_email,
"after_login": "renew",
"renew": "1",
}
)
tariff_key = self._renewal_tariff_key(sub)
if tariff_key:
query["renew_tariff"] = tariff_key
else:
query.pop("renew_tariff", None)
return urlunsplit(
(
parsed.scheme,
parsed.netloc,
parsed.path or "/",
urlencode(query),
parsed.fragment,
)
)
@staticmethod
def _renewal_tariff_key(sub: Subscription) -> str:
provider = str(getattr(sub, "provider", "") or "").strip().lower()
status = str(getattr(sub, "status_from_panel", "") or "").strip().upper()
if provider == "trial" or status == "TRIAL":
return ""
return str(getattr(sub, "tariff_key", "") or "").strip()
@staticmethod
def _telegram_chat_id(user: Optional[User], fallback_user_id: Optional[int]) -> Optional[int]:
for candidate in (getattr(user, "telegram_id", None), fallback_user_id):
@@ -805,9 +805,11 @@ class SubscriptionLifecycleMixin:
local_active_sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, panel_user_uuid
)
panel_user_data, panel_user_confirmed_absent, panel_lookup_failure_reason = (
await self._lookup_panel_user_for_subscription_details(panel_user_uuid)
)
(
panel_user_data,
panel_user_confirmed_absent,
panel_lookup_failure_reason,
) = await self._lookup_panel_user_for_subscription_details(panel_user_uuid)
if not panel_user_data:
if panel_user_confirmed_absent:
+82
View File
@@ -152,6 +152,7 @@
let mode = isAppLaunchRoute ? "appLaunch" : isPreviewBoard ? "preview" : "loading";
let activeTab = "home";
let screen = "home";
let emailLoginDeeplinkConsumed = false;
let data = isPreviewBoard ? structuredCloneSafe(MOCK_SOURCE.data) : null;
let appLaunchTarget = isAppLaunchRoute ? readExternalAppLaunchTarget() : "";
let publicInstallSubscription = null;
@@ -1142,6 +1143,61 @@
return new URLSearchParams(window.location.search);
}
function readEmailCodeLoginDeeplink() {
const params = currentSearchParams();
if (params.get("login") !== "email_code") return null;
const emailHint = normalizedEmail(params.get("login_email") || "");
if (!emailHint || !emailHint.includes("@")) return null;
return emailHint;
}
function hasEmailCodeLoginDeeplink() {
return Boolean(readEmailCodeLoginDeeplink());
}
async function startEmailCodeLoginFromDeeplink() {
if (emailLoginDeeplinkConsumed) return;
const emailHint = readEmailCodeLoginDeeplink();
if (!emailHint) return;
emailLoginDeeplinkConsumed = true;
authStore.update((s) => ({
...s,
email: emailHint,
emailCode: "",
pendingEmail: "",
passwordLoginMode: false,
passwordLoginFallback: false,
}));
await tick();
await authStore.requestEmailCode((nextScreen) => {
screen = nextScreen;
});
}
function readRenewalDeeplink() {
const params = currentSearchParams();
const shouldRenew = params.get("after_login") === "renew" || params.get("renew") === "1";
if (!shouldRenew) return null;
return {
tariffKey: String(params.get("renew_tariff") || "").trim(),
};
}
function stripRenewalLoginQueryFromUrl() {
if (typeof window === "undefined") return;
const url = new URL(window.location.href);
const keys = ["login", "login_email", "after_login", "renew", "renew_tariff"];
const changed = keys.some((key) => url.searchParams.has(key));
if (!changed) return;
for (const key of keys) url.searchParams.delete(key);
const search = url.searchParams.toString();
window.history.replaceState(
null,
"",
`${url.pathname}${search ? `?${search}` : ""}${url.hash}`
);
}
function docsDemoParentSearchParams() {
if (!isDocsDemo) return null;
try {
@@ -1317,6 +1373,7 @@
clearToken,
clearManualLogoutFlag,
isManuallyLoggedOut,
hasEmailCodeLoginDeeplink,
finalizeMagicLogin: (loginToken) => authStore.finalizeMagicLogin(loginToken),
finalizeTelegramAuth: (authData, source) => authStore.finalizeTelegramAuth(authData, source),
setAuthStatus: (message, isError) => authStore.setAuthStatus(message, isError),
@@ -1509,6 +1566,30 @@
stripTopupQueryFromUrl();
}
}
const renewalDeep = readRenewalDeeplink();
if (renewalDeep) {
const plansList = payload.plans?.length ? payload.plans : [];
const tariffCatalogLocal = buildTariffCatalog(plansList);
const tariffModeLocal = plansList.some((plan) => plan?.tariff_key);
activeTab = "home";
screen = "home";
syncAppSectionPath("home", true);
billingStore.openPaymentModal(
tariffModeLocal,
tariffModeLocal && tariffCatalogLocal.length === 1,
tariffCatalogLocal,
payload.subscription || {},
plansList,
payload.payment_methods?.[0]?.id || "",
{
preferredTariffKey: renewalDeep.tariffKey,
selectDefaultTariff: true,
preferCheckout: true,
}
);
stripRenewalLoginQueryFromUrl();
}
return payload;
}
@@ -1530,6 +1611,7 @@
screen = "login";
activeTab = "home";
setPasswordLoginMode(isPasswordLoginPath(), true);
void startEmailCodeLoginFromDeeplink();
}
async function api(path, options = {}) {
+24 -6
View File
@@ -110,25 +110,43 @@ export function createBillingStore({
tariffCatalog,
subscription,
plans,
defaultMethod = ""
defaultMethod = "",
options = {}
) {
state.update((s) => {
let step;
let plan = s.selectedPlan;
let tariffKey = s.selectedTariffKey;
const catalog = tariffCatalog || [];
const planList = plans || [];
const preferredTariffKey = String(options?.preferredTariffKey || "").trim();
const preferredTariff = preferredTariffKey
? catalog.find((tariff) => tariff.key === preferredTariffKey)
: null;
const fallbackTariff =
catalog.find((tariff) => tariff.is_default) ||
catalog.find((tariff) => tariff.key === "standard") ||
catalog[0] ||
null;
const deeplinkTariff =
preferredTariff || (options?.selectDefaultTariff ? fallbackTariff : null);
if (tariffMode) {
if (singleTariffMode && tariffCatalog[0]?.key) {
tariffKey = tariffCatalog[0].key;
plan = plans.find((p) => p?.tariff_key === tariffKey) || null;
if (deeplinkTariff?.key) {
tariffKey = deeplinkTariff.key;
plan = planList.find((p) => p?.tariff_key === tariffKey) || null;
step = options?.preferCheckout && plan ? "checkout" : "tariff";
} else if (singleTariffMode && catalog[0]?.key) {
tariffKey = catalog[0].key;
plan = planList.find((p) => p?.tariff_key === tariffKey) || null;
step = "checkout";
} else if (
subscription?.active &&
subscription?.tariff_key &&
tariffCatalog.some((t) => t.key === subscription.tariff_key)
catalog.some((t) => t.key === subscription.tariff_key)
) {
tariffKey = subscription.tariff_key;
plan = plans.find((p) => p?.tariff_key === tariffKey) || null;
plan = planList.find((p) => p?.tariff_key === tariffKey) || null;
step = "checkout";
} else {
step = "tariff";
+2
View File
@@ -21,11 +21,13 @@ export function buildTariffCatalog(planList) {
(plan?.sale_mode === "traffic_package" || plan?.sale_mode === "traffic"
? "traffic"
: "period"),
is_default: Boolean(plan?.is_default_tariff),
monthly_gb: Number(plan?.monthly_gb || 0),
traffic_packages: [],
plans_count: 0,
};
if (!entry.description && plan?.description) entry.description = plan.description;
if (plan?.is_default_tariff) entry.is_default = true;
if (!entry.monthly_gb && Number(plan?.monthly_gb || 0) > 0)
entry.monthly_gb = Number(plan.monthly_gb);
const trafficGb = Number(plan?.traffic_gb || 0);
+8
View File
@@ -21,6 +21,7 @@ export async function runWebappBoot({
clearToken,
clearManualLogoutFlag,
isManuallyLoggedOut,
hasEmailCodeLoginDeeplink,
finalizeMagicLogin,
finalizeTelegramAuth,
setAuthStatus,
@@ -38,6 +39,13 @@ export async function runWebappBoot({
return;
}
if (hasEmailCodeLoginDeeplink?.()) {
clearManualLogoutFlag();
clearToken();
showLogin();
return;
}
const magicToken = readMagicLoginToken();
if (magicToken && (await finalizeMagicLogin(magicToken))) return;
+3 -1
View File
@@ -630,7 +630,9 @@
"email_subscription_lifecycle_subject_expired": "Your subscription has expired",
"email_subscription_lifecycle_subject_expired_after": "Your subscription expired yesterday",
"email_subscription_lifecycle_subject_autorenew": "Auto-renewal runs tomorrow",
"email_subscription_lifecycle_intro": "This notification is mirrored from Telegram so you do not miss an important subscription event.",
"email_subscription_lifecycle_intro": "This is an important subscription notification.",
"email_subscription_lifecycle_intro_direct": "This is an important subscription notification sent to your linked email address.",
"email_subscription_lifecycle_intro_mirrored": "This notification is mirrored from Telegram so you do not miss an important subscription event.",
"email_subscription_lifecycle_row_end_date": "Active until",
"email_subscription_lifecycle_cta": "Open dashboard",
"email_subscription_lifecycle_text_renew": "Dashboard: {url}",
+3 -1
View File
@@ -630,7 +630,9 @@
"email_subscription_lifecycle_subject_expired": "Подписка закончилась",
"email_subscription_lifecycle_subject_expired_after": "Подписка закончилась сутки назад",
"email_subscription_lifecycle_subject_autorenew": "Завтра автопродление подписки",
"email_subscription_lifecycle_intro": "Это уведомление продублировано из Telegram, чтобы вы не пропустили важное событие по подписке.",
"email_subscription_lifecycle_intro": "Это уведомление по важному событию подписки.",
"email_subscription_lifecycle_intro_direct": "Это уведомление по важному событию подписки. Отправляем его на привязанную почту, чтобы вы ничего не пропустили.",
"email_subscription_lifecycle_intro_mirrored": "Это уведомление продублировано из Telegram, чтобы вы не пропустили важное событие по подписке.",
"email_subscription_lifecycle_row_end_date": "Действует до",
"email_subscription_lifecycle_cta": "Открыть кабинет",
"email_subscription_lifecycle_text_renew": "Кабинет: {url}",
@@ -1,6 +1,7 @@
import asyncio
from datetime import datetime, timezone
from types import SimpleNamespace
from urllib.parse import parse_qs, urlsplit
from aiogram.exceptions import TelegramBadRequest
from aiogram.methods import SendMessage
@@ -22,6 +23,8 @@ class FakeI18n:
"email_subscription_lifecycle_subject_expired_after": "Expired yesterday",
"email_subscription_lifecycle_subject_autorenew": "Auto-renewal tomorrow",
"email_subscription_lifecycle_intro": "Subscription notice",
"email_subscription_lifecycle_intro_direct": "Direct email notice",
"email_subscription_lifecycle_intro_mirrored": "Mirrored Telegram notice",
"email_subscription_lifecycle_row_end_date": "Active until",
"email_subscription_lifecycle_cta": "Open dashboard",
"email_subscription_lifecycle_text_renew": "Dashboard: {url}",
@@ -78,23 +81,29 @@ def _settings(**overrides):
)
def _subscription():
return SimpleNamespace(
subscription_id=42,
user_id=123,
end_date=datetime(2026, 6, 1, tzinfo=timezone.utc),
)
def _subscription(**overrides):
data = {
"subscription_id": 42,
"user_id": 123,
"end_date": datetime(2026, 6, 1, tzinfo=timezone.utc),
"tariff_key": "standard",
"provider": "yookassa",
"status_from_panel": "ACTIVE",
}
data.update(overrides)
return SimpleNamespace(**data)
def _user(**overrides):
return SimpleNamespace(
user_id=123,
telegram_id=555,
email="user@example.test",
language_code="ru",
first_name="Ada",
**overrides,
)
data = {
"user_id": 123,
"telegram_id": 555,
"email": "user@example.test",
"language_code": "ru",
"first_name": "Ada",
}
data.update(overrides)
return SimpleNamespace(**data)
def test_send_stage_records_telegram_and_email_channel_keys(monkeypatch):
@@ -143,6 +152,7 @@ def test_send_stage_records_telegram_and_email_channel_keys(monkeypatch):
}
]
assert email_service.messages[0]["email"] == "user@example.test"
assert "Mirrored Telegram notice" in email_service.messages[0]["content"].html
assert recorded == ["before_3d:telegram", "before_3d:email"]
@@ -232,3 +242,113 @@ def test_terminal_telegram_failure_is_recorded_to_avoid_retry_spam(monkeypatch):
assert delivery.email_sent is False
assert bot.calls[0][0] == 777
assert recorded == ["before_3d:telegram"]
def test_email_only_user_gets_email_name_direct_copy_and_renewal_login_link(monkeypatch):
recorded = []
async def fake_has(session, subscription_id, notification_key):
return notification_key in recorded
async def fake_record(session, subscription_id, notification_key, *, sent_at=None):
recorded.append(notification_key)
monkeypatch.setattr(lifecycle.subscription_dal, "has_subscription_notification", fake_has)
monkeypatch.setattr(lifecycle.subscription_dal, "record_subscription_notification", fake_record)
bot = FakeBot()
email_service = FakeEmailService()
service = SubscriptionLifecycleNotificationService(
_settings(),
bot,
FakeI18n(),
email_service=email_service,
)
user = _user(user_id=-8758169927032035, telegram_id=None, first_name=None)
async def run():
return await service.send_stage(
object(),
_subscription(user_id=user.user_id),
SubscriptionNotificationStage(
key="expired",
message_key="subscription_72h_notification",
days_left=0,
),
user=user,
telegram_markup="markup",
)
delivery = asyncio.run(run())
assert delivery.telegram_sent is False
assert delivery.email_sent is True
assert bot.messages == []
content = email_service.messages[0]["content"]
assert "Hi user@example.test, expires on 2026-06-01" in content.text
assert "User -8758169927032035" not in content.text
assert "Direct email notice" in content.html
assert "Mirrored Telegram notice" not in content.html
dashboard_line = next(
line for line in content.text.splitlines() if line.startswith("Dashboard: ")
)
url = dashboard_line.removeprefix("Dashboard: ")
parsed = urlsplit(url)
query = parse_qs(parsed.query)
assert parsed.scheme == "https"
assert parsed.netloc == "app.example.test"
assert query["login"] == ["email_code"]
assert query["login_email"] == ["user@example.test"]
assert query["after_login"] == ["renew"]
assert query["renew"] == ["1"]
assert query["renew_tariff"] == ["standard"]
assert recorded == ["expired:email"]
def test_trial_subscription_renewal_link_omits_tariff_for_default_fallback(monkeypatch):
recorded = []
async def fake_has(session, subscription_id, notification_key):
return notification_key in recorded
async def fake_record(session, subscription_id, notification_key, *, sent_at=None):
recorded.append(notification_key)
monkeypatch.setattr(lifecycle.subscription_dal, "has_subscription_notification", fake_has)
monkeypatch.setattr(lifecycle.subscription_dal, "record_subscription_notification", fake_record)
email_service = FakeEmailService()
service = SubscriptionLifecycleNotificationService(
_settings(),
FakeBot(),
FakeI18n(),
email_service=email_service,
)
user = _user(user_id=-1001, telegram_id=None, first_name=None)
async def run():
return await service.send_stage(
object(),
_subscription(
user_id=user.user_id,
tariff_key="trial-tariff",
provider="trial",
status_from_panel="TRIAL",
),
SubscriptionNotificationStage(
key="expired",
message_key="subscription_72h_notification",
days_left=0,
),
user=user,
)
asyncio.run(run())
content = email_service.messages[0]["content"]
dashboard_line = next(
line for line in content.text.splitlines() if line.startswith("Dashboard: ")
)
query = parse_qs(urlsplit(dashboard_line.removeprefix("Dashboard: ")).query)
assert "renew_tariff" not in query
+1 -3
View File
@@ -88,9 +88,7 @@ class TariffWorkerTests(unittest.IsolatedAsyncioTestCase):
super().__init__("wrapped")
self.orig = orig
self.assertTrue(
TariffTrafficWorker._is_retryable_db_exception(WrappedDbError(PgError()))
)
self.assertTrue(TariffTrafficWorker._is_retryable_db_exception(WrappedDbError(PgError())))
self.assertFalse(TariffTrafficWorker._is_retryable_db_exception(RuntimeError("plain")))
async def test_period_tariff_uses_panel_month_strategy_without_resetting(self):
+2
View File
@@ -76,10 +76,12 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual([plan["tariff_key"] for plan in plans], ["standard", "traffic"])
self.assertEqual(plans[0]["sale_mode"], "subscription")
self.assertTrue(plans[0]["is_default_tariff"])
self.assertEqual(plans[0]["months"], 1)
self.assertEqual(plans[0]["hwid_device_limit"], 5)
self.assertEqual(plans[0]["hwid_device_packages"][0]["device_count"], 1)
self.assertEqual(plans[1]["sale_mode"], "traffic_package")
self.assertFalse(plans[1]["is_default_tariff"])
self.assertEqual(plans[1]["traffic_gb"], 50.0)
self.assertEqual(plans[1]["stars_price"], 2500)