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)
|
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),
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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 = {}) {
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user