fix: improve subscription email renewal flow
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -149,7 +149,7 @@ class Tariff(BaseModel):
|
||||
if rub_price <= 0 and stars_price <= 0:
|
||||
raise ValueError(
|
||||
f"period tariff {self.key}: period {months} needs a non-zero rub or stars price" # noqa: E501
|
||||
)
|
||||
)
|
||||
return self
|
||||
|
||||
if not self.traffic_packages or not self.traffic_packages.has_any():
|
||||
|
||||
@@ -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 = {}) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user