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) return sorted(int(months) for months in settings.subscription_options)
def _serialize_tariff_period_referral_bonus_details( def _serialize_tariff_period_referral_bonus_details(tariff: Any, lang: str) -> List[Dict[str, Any]]:
tariff: Any, lang: str
) -> List[Dict[str, Any]]:
details: List[Dict[str, Any]] = [] details: List[Dict[str, Any]] = []
for months in sorted(int(month) for month in tariff.enabled_periods): for months in sorted(int(month) for month in tariff.enabled_periods):
inviter_days = tariff.referral_inviter_bonus_days(months) inviter_days = tariff.referral_inviter_bonus_days(months)
@@ -460,6 +458,7 @@ def _serialize_plans(
for tariff in tariffs_config.enabled_tariffs: for tariff in tariffs_config.enabled_tariffs:
common = { common = {
"tariff_key": tariff.key, "tariff_key": tariff.key,
"is_default_tariff": tariff.key == tariffs_config.default_tariff,
"tariff_name": tariff.name(lang), "tariff_name": tariff.name(lang),
"billing_model": tariff.billing_model, "billing_model": tariff.billing_model,
"description": tariff.description(lang), "description": tariff.description(lang),
+7 -1
View File
@@ -534,6 +534,7 @@ def render_subscription_lifecycle_notification(
message_text: str, message_text: str,
end_date_text: str, end_date_text: str,
dashboard_url: Optional[str], dashboard_url: Optional[str],
mirrored_from_telegram: bool = False,
days_left: Optional[int] = None, days_left: Optional[int] = None,
hours_before: Optional[int] = None, hours_before: Optional[int] = None,
i18n: Optional[JsonI18n] = None, i18n: Optional[JsonI18n] = None,
@@ -551,7 +552,12 @@ def render_subscription_lifecycle_notification(
days_left=days_left, days_left=days_left,
hours_before=hours_before, 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) footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
cta_label = _t_text(i18n, lang, "email_subscription_lifecycle_cta") cta_label = _t_text(i18n, lang, "email_subscription_lifecycle_cta")
@@ -2,6 +2,7 @@ import logging
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from aiogram import Bot from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
@@ -67,20 +68,30 @@ class SubscriptionLifecycleNotificationService:
resolved_user = user or getattr(sub, "user", None) resolved_user = user or getattr(sub, "user", None)
lang = getattr(resolved_user, "language_code", None) or self.settings.DEFAULT_LANGUAGE lang = getattr(resolved_user, "language_code", None) or self.settings.DEFAULT_LANGUAGE
user_id = int(getattr(sub, "user_id", 0) or 0) 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 final_end_date_text = end_date_text
if final_end_date_text is None: if final_end_date_text is None:
end_date = self._as_utc(getattr(sub, "end_date", 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 "" 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: if stage.hours_before is not None:
kwargs["hours"] = stage.hours_before kwargs["hours"] = stage.hours_before
message_text = self.i18n.gettext(lang, stage.message_key, **kwargs) 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() final_extra_text = str(extra_text or "").strip()
if final_extra_text: if final_extra_text:
message_text = f"{message_text}\n\n{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( telegram_sent = await self._send_telegram(
session, session,
@@ -98,8 +109,10 @@ class SubscriptionLifecycleNotificationService:
stage, stage,
resolved_user, resolved_user,
lang=lang, lang=lang,
message_text=message_text, message_text=email_message_text,
end_date_text=final_end_date_text, end_date_text=final_end_date_text,
recipient=recipient_email,
telegram_sent=telegram_sent,
sent_at=sent_at, sent_at=sent_at,
) )
return SubscriptionNotificationDelivery( return SubscriptionNotificationDelivery(
@@ -180,13 +193,14 @@ class SubscriptionLifecycleNotificationService:
lang: str, lang: str,
message_text: str, message_text: str,
end_date_text: str, end_date_text: str,
recipient: str,
telegram_sent: bool,
sent_at: datetime, sent_at: datetime,
) -> bool: ) -> bool:
if not getattr(self.settings, "SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED", True): if not getattr(self.settings, "SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED", True):
return False return False
if not getattr(self.settings, "email_auth_configured", False): if not getattr(self.settings, "email_auth_configured", False):
return False return False
recipient = str(getattr(user, "email", "") or "").strip() if user else ""
if not recipient: if not recipient:
return False return False
if await self._already_sent(session, sub.subscription_id, stage.key, "email"): if await self._already_sent(session, sub.subscription_id, stage.key, "email"):
@@ -199,7 +213,8 @@ class SubscriptionLifecycleNotificationService:
notification_key=stage.key, notification_key=stage.key,
message_text=message_text, message_text=message_text,
end_date_text=end_date_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, days_left=stage.days_left,
hours_before=stage.hours_before, hours_before=stage.hours_before,
i18n=self.i18n, i18n=self.i18n,
@@ -249,6 +264,64 @@ class SubscriptionLifecycleNotificationService:
def _channel_key(stage_key: str, channel: str) -> str: def _channel_key(stage_key: str, channel: str) -> str:
return f"{stage_key}:{channel}" 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 @staticmethod
def _telegram_chat_id(user: Optional[User], fallback_user_id: Optional[int]) -> Optional[int]: 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): 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( local_active_sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, panel_user_uuid 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 not panel_user_data:
if panel_user_confirmed_absent: if panel_user_confirmed_absent:
+1 -1
View File
@@ -149,7 +149,7 @@ class Tariff(BaseModel):
if rub_price <= 0 and stars_price <= 0: if rub_price <= 0 and stars_price <= 0:
raise ValueError( raise ValueError(
f"period tariff {self.key}: period {months} needs a non-zero rub or stars price" # noqa: E501 f"period tariff {self.key}: period {months} needs a non-zero rub or stars price" # noqa: E501
) )
return self return self
if not self.traffic_packages or not self.traffic_packages.has_any(): if not self.traffic_packages or not self.traffic_packages.has_any():
+82
View File
@@ -152,6 +152,7 @@
let mode = isAppLaunchRoute ? "appLaunch" : isPreviewBoard ? "preview" : "loading"; let mode = isAppLaunchRoute ? "appLaunch" : isPreviewBoard ? "preview" : "loading";
let activeTab = "home"; let activeTab = "home";
let screen = "home"; let screen = "home";
let emailLoginDeeplinkConsumed = false;
let data = isPreviewBoard ? structuredCloneSafe(MOCK_SOURCE.data) : null; let data = isPreviewBoard ? structuredCloneSafe(MOCK_SOURCE.data) : null;
let appLaunchTarget = isAppLaunchRoute ? readExternalAppLaunchTarget() : ""; let appLaunchTarget = isAppLaunchRoute ? readExternalAppLaunchTarget() : "";
let publicInstallSubscription = null; let publicInstallSubscription = null;
@@ -1142,6 +1143,61 @@
return new URLSearchParams(window.location.search); 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() { function docsDemoParentSearchParams() {
if (!isDocsDemo) return null; if (!isDocsDemo) return null;
try { try {
@@ -1317,6 +1373,7 @@
clearToken, clearToken,
clearManualLogoutFlag, clearManualLogoutFlag,
isManuallyLoggedOut, isManuallyLoggedOut,
hasEmailCodeLoginDeeplink,
finalizeMagicLogin: (loginToken) => authStore.finalizeMagicLogin(loginToken), finalizeMagicLogin: (loginToken) => authStore.finalizeMagicLogin(loginToken),
finalizeTelegramAuth: (authData, source) => authStore.finalizeTelegramAuth(authData, source), finalizeTelegramAuth: (authData, source) => authStore.finalizeTelegramAuth(authData, source),
setAuthStatus: (message, isError) => authStore.setAuthStatus(message, isError), setAuthStatus: (message, isError) => authStore.setAuthStatus(message, isError),
@@ -1509,6 +1566,30 @@
stripTopupQueryFromUrl(); 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; return payload;
} }
@@ -1530,6 +1611,7 @@
screen = "login"; screen = "login";
activeTab = "home"; activeTab = "home";
setPasswordLoginMode(isPasswordLoginPath(), true); setPasswordLoginMode(isPasswordLoginPath(), true);
void startEmailCodeLoginFromDeeplink();
} }
async function api(path, options = {}) { async function api(path, options = {}) {
+24 -6
View File
@@ -110,25 +110,43 @@ export function createBillingStore({
tariffCatalog, tariffCatalog,
subscription, subscription,
plans, plans,
defaultMethod = "" defaultMethod = "",
options = {}
) { ) {
state.update((s) => { state.update((s) => {
let step; let step;
let plan = s.selectedPlan; let plan = s.selectedPlan;
let tariffKey = s.selectedTariffKey; 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 (tariffMode) {
if (singleTariffMode && tariffCatalog[0]?.key) { if (deeplinkTariff?.key) {
tariffKey = tariffCatalog[0].key; tariffKey = deeplinkTariff.key;
plan = plans.find((p) => p?.tariff_key === tariffKey) || null; 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"; step = "checkout";
} else if ( } else if (
subscription?.active && subscription?.active &&
subscription?.tariff_key && subscription?.tariff_key &&
tariffCatalog.some((t) => t.key === subscription.tariff_key) catalog.some((t) => t.key === subscription.tariff_key)
) { ) {
tariffKey = 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"; step = "checkout";
} else { } else {
step = "tariff"; step = "tariff";
+2
View File
@@ -21,11 +21,13 @@ export function buildTariffCatalog(planList) {
(plan?.sale_mode === "traffic_package" || plan?.sale_mode === "traffic" (plan?.sale_mode === "traffic_package" || plan?.sale_mode === "traffic"
? "traffic" ? "traffic"
: "period"), : "period"),
is_default: Boolean(plan?.is_default_tariff),
monthly_gb: Number(plan?.monthly_gb || 0), monthly_gb: Number(plan?.monthly_gb || 0),
traffic_packages: [], traffic_packages: [],
plans_count: 0, plans_count: 0,
}; };
if (!entry.description && plan?.description) entry.description = plan.description; 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) if (!entry.monthly_gb && Number(plan?.monthly_gb || 0) > 0)
entry.monthly_gb = Number(plan.monthly_gb); entry.monthly_gb = Number(plan.monthly_gb);
const trafficGb = Number(plan?.traffic_gb || 0); const trafficGb = Number(plan?.traffic_gb || 0);
+8
View File
@@ -21,6 +21,7 @@ export async function runWebappBoot({
clearToken, clearToken,
clearManualLogoutFlag, clearManualLogoutFlag,
isManuallyLoggedOut, isManuallyLoggedOut,
hasEmailCodeLoginDeeplink,
finalizeMagicLogin, finalizeMagicLogin,
finalizeTelegramAuth, finalizeTelegramAuth,
setAuthStatus, setAuthStatus,
@@ -38,6 +39,13 @@ export async function runWebappBoot({
return; return;
} }
if (hasEmailCodeLoginDeeplink?.()) {
clearManualLogoutFlag();
clearToken();
showLogin();
return;
}
const magicToken = readMagicLoginToken(); const magicToken = readMagicLoginToken();
if (magicToken && (await finalizeMagicLogin(magicToken))) return; 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": "Your subscription has expired",
"email_subscription_lifecycle_subject_expired_after": "Your subscription expired yesterday", "email_subscription_lifecycle_subject_expired_after": "Your subscription expired yesterday",
"email_subscription_lifecycle_subject_autorenew": "Auto-renewal runs tomorrow", "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_row_end_date": "Active until",
"email_subscription_lifecycle_cta": "Open dashboard", "email_subscription_lifecycle_cta": "Open dashboard",
"email_subscription_lifecycle_text_renew": "Dashboard: {url}", "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": "Подписка закончилась",
"email_subscription_lifecycle_subject_expired_after": "Подписка закончилась сутки назад", "email_subscription_lifecycle_subject_expired_after": "Подписка закончилась сутки назад",
"email_subscription_lifecycle_subject_autorenew": "Завтра автопродление подписки", "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_row_end_date": "Действует до",
"email_subscription_lifecycle_cta": "Открыть кабинет", "email_subscription_lifecycle_cta": "Открыть кабинет",
"email_subscription_lifecycle_text_renew": "Кабинет: {url}", "email_subscription_lifecycle_text_renew": "Кабинет: {url}",
@@ -1,6 +1,7 @@
import asyncio import asyncio
from datetime import datetime, timezone from datetime import datetime, timezone
from types import SimpleNamespace from types import SimpleNamespace
from urllib.parse import parse_qs, urlsplit
from aiogram.exceptions import TelegramBadRequest from aiogram.exceptions import TelegramBadRequest
from aiogram.methods import SendMessage from aiogram.methods import SendMessage
@@ -22,6 +23,8 @@ class FakeI18n:
"email_subscription_lifecycle_subject_expired_after": "Expired yesterday", "email_subscription_lifecycle_subject_expired_after": "Expired yesterday",
"email_subscription_lifecycle_subject_autorenew": "Auto-renewal tomorrow", "email_subscription_lifecycle_subject_autorenew": "Auto-renewal tomorrow",
"email_subscription_lifecycle_intro": "Subscription notice", "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_row_end_date": "Active until",
"email_subscription_lifecycle_cta": "Open dashboard", "email_subscription_lifecycle_cta": "Open dashboard",
"email_subscription_lifecycle_text_renew": "Dashboard: {url}", "email_subscription_lifecycle_text_renew": "Dashboard: {url}",
@@ -78,23 +81,29 @@ def _settings(**overrides):
) )
def _subscription(): def _subscription(**overrides):
return SimpleNamespace( data = {
subscription_id=42, "subscription_id": 42,
user_id=123, "user_id": 123,
end_date=datetime(2026, 6, 1, tzinfo=timezone.utc), "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): def _user(**overrides):
return SimpleNamespace( data = {
user_id=123, "user_id": 123,
telegram_id=555, "telegram_id": 555,
email="user@example.test", "email": "user@example.test",
language_code="ru", "language_code": "ru",
first_name="Ada", "first_name": "Ada",
**overrides, }
) data.update(overrides)
return SimpleNamespace(**data)
def test_send_stage_records_telegram_and_email_channel_keys(monkeypatch): 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 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"] 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 delivery.email_sent is False
assert bot.calls[0][0] == 777 assert bot.calls[0][0] == 777
assert recorded == ["before_3d:telegram"] 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") super().__init__("wrapped")
self.orig = orig self.orig = orig
self.assertTrue( self.assertTrue(TariffTrafficWorker._is_retryable_db_exception(WrappedDbError(PgError())))
TariffTrafficWorker._is_retryable_db_exception(WrappedDbError(PgError()))
)
self.assertFalse(TariffTrafficWorker._is_retryable_db_exception(RuntimeError("plain"))) self.assertFalse(TariffTrafficWorker._is_retryable_db_exception(RuntimeError("plain")))
async def test_period_tariff_uses_panel_month_strategy_without_resetting(self): 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([plan["tariff_key"] for plan in plans], ["standard", "traffic"])
self.assertEqual(plans[0]["sale_mode"], "subscription") self.assertEqual(plans[0]["sale_mode"], "subscription")
self.assertTrue(plans[0]["is_default_tariff"])
self.assertEqual(plans[0]["months"], 1) self.assertEqual(plans[0]["months"], 1)
self.assertEqual(plans[0]["hwid_device_limit"], 5) self.assertEqual(plans[0]["hwid_device_limit"], 5)
self.assertEqual(plans[0]["hwid_device_packages"][0]["device_count"], 1) self.assertEqual(plans[0]["hwid_device_packages"][0]["device_count"], 1)
self.assertEqual(plans[1]["sale_mode"], "traffic_package") 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]["traffic_gb"], 50.0)
self.assertEqual(plans[1]["stars_price"], 2500) self.assertEqual(plans[1]["stars_price"], 2500)