feat: support traffic and trial flow in web app
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
CircleX,
|
||||
Copy,
|
||||
CreditCard,
|
||||
Database,
|
||||
FileText,
|
||||
Gift,
|
||||
Globe2,
|
||||
@@ -131,12 +132,18 @@
|
||||
settings: {
|
||||
support_url: "https://t.me/support",
|
||||
traffic_mode: false,
|
||||
trial_enabled: true,
|
||||
trial_available: true,
|
||||
trial_duration_days: 5,
|
||||
trial_traffic_limit_gb: 10,
|
||||
trial_traffic_strategy: "NO_RESET",
|
||||
email_auth_enabled: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const query = new URLSearchParams(window.location.search);
|
||||
applyPreviewMock(query.get("mock"));
|
||||
const isPreviewBoard = query.get("preview") === "all";
|
||||
const injectedConfig = readJsonScript("webapp-config");
|
||||
const injectedI18n = readJsonScript("i18n");
|
||||
@@ -160,6 +167,7 @@
|
||||
let selectedMethod = "";
|
||||
let paymentModalOpen = false;
|
||||
let payBusy = false;
|
||||
let trialBusy = false;
|
||||
let linkEmailOpen = false;
|
||||
let linkEmailBusy = false;
|
||||
let linkTelegramBusy = false;
|
||||
@@ -196,11 +204,59 @@
|
||||
let linkEmailResendTimer = null;
|
||||
let scrollLockApplied = false;
|
||||
|
||||
function applyPreviewMock(kind) {
|
||||
const mode = String(kind || "").trim().toLowerCase();
|
||||
if (mode === "traffic") {
|
||||
DEV_MOCK.data.settings.traffic_mode = true;
|
||||
DEV_MOCK.data.settings.trial_available = false;
|
||||
DEV_MOCK.data.subscription = {
|
||||
...DEV_MOCK.data.subscription,
|
||||
active: true,
|
||||
status: "ACTIVE",
|
||||
remaining_text: "Навсегда",
|
||||
end_date_text: "01.01.2099 00:00",
|
||||
days_left: 26000,
|
||||
traffic_used: "18.4 GB",
|
||||
traffic_limit: "100 GB",
|
||||
traffic_used_bytes: 19756849561,
|
||||
traffic_limit_bytes: 107374182400,
|
||||
traffic_limit_strategy: "NO_RESET",
|
||||
};
|
||||
DEV_MOCK.data.plans = [
|
||||
{ months: 10, traffic_gb: 10, price: 199, currency: "RUB", title: "10 GB", sale_mode: "traffic" },
|
||||
{ months: 50, traffic_gb: 50, price: 799, currency: "RUB", title: "50 GB", sale_mode: "traffic" },
|
||||
{ months: 100, traffic_gb: 100, price: 1390, currency: "RUB", title: "100 GB", sale_mode: "traffic" },
|
||||
{ months: 300, traffic_gb: 300, price: 3490, currency: "RUB", title: "300 GB", sale_mode: "traffic" },
|
||||
];
|
||||
} else if (mode === "trial") {
|
||||
DEV_MOCK.data.settings.traffic_mode = false;
|
||||
DEV_MOCK.data.settings.trial_enabled = true;
|
||||
DEV_MOCK.data.settings.trial_available = true;
|
||||
DEV_MOCK.data.settings.trial_duration_days = 5;
|
||||
DEV_MOCK.data.settings.trial_traffic_limit_gb = 10;
|
||||
DEV_MOCK.data.subscription = {
|
||||
active: false,
|
||||
status: "INACTIVE",
|
||||
remaining_text: "Подписка не активна",
|
||||
end_date_text: "",
|
||||
days_left: 0,
|
||||
config_link: null,
|
||||
connect_url: null,
|
||||
traffic_used: "0 B",
|
||||
traffic_limit: "10 GB",
|
||||
traffic_used_bytes: 0,
|
||||
traffic_limit_bytes: 10737418240,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
$: brandTitle = CFG.title || "/minishop";
|
||||
$: brandEmoji = CFG.logoEmoji || "🫥";
|
||||
$: accent = CFG.primaryColor || "#00fe7a";
|
||||
$: plans = data?.plans?.length ? data.plans : DEV_MOCK.data.plans;
|
||||
$: methods = data?.payment_methods?.length ? data.payment_methods : [];
|
||||
$: appSettings = data?.settings || DEV_MOCK.data.settings;
|
||||
$: trafficMode = Boolean(appSettings?.traffic_mode);
|
||||
$: subscription = data?.subscription || DEV_MOCK.data.subscription;
|
||||
$: user = data?.user || {};
|
||||
$: referral = data?.referral || DEV_MOCK.data.referral;
|
||||
@@ -224,7 +280,7 @@
|
||||
$: profileAvatarUrl = user?.telegram_photo_url || emailAvatarUrl || "";
|
||||
$: privacyPolicyUrl = String(CFG.privacyPolicyUrl || "").trim();
|
||||
$: userAgreementUrl = String(CFG.userAgreementUrl || "").trim();
|
||||
$: supportUrl = String(data?.settings?.support_url || CFG.supportUrl || "").trim();
|
||||
$: supportUrl = String(appSettings?.support_url || CFG.supportUrl || "").trim();
|
||||
$: telegramLoginBotId = Number(CFG.telegramLoginBotId || 0);
|
||||
$: applyFavicon(CFG.logoUrl, brandEmoji);
|
||||
$: syncBodyScrollLock(paymentModalOpen || linkEmailOpen);
|
||||
@@ -371,6 +427,7 @@
|
||||
}
|
||||
|
||||
function syncSectionPath(section, replace = false) {
|
||||
if (window.location.protocol === "file:") return;
|
||||
const normalized = normalizeSection(section);
|
||||
const targetPath = APP_SECTION_PATHS[normalized] || APP_SECTION_PATHS.home;
|
||||
if (window.location.pathname === targetPath) return;
|
||||
@@ -482,6 +539,22 @@
|
||||
return { ok: true, token: "local-preview", csrf_token: "local-preview-csrf" };
|
||||
}
|
||||
if (path === "/promo/apply") return { ok: true, end_date_text: "31.05.2026" };
|
||||
if (path === "/trial/activate" && String(options.method || "").toUpperCase() === "POST") {
|
||||
DEV_MOCK.data.subscription = {
|
||||
...DEV_MOCK.data.subscription,
|
||||
active: true,
|
||||
status: "TRIAL",
|
||||
remaining_text: "5 д. 0 ч.",
|
||||
end_date_text: "05.05.2026 12:00",
|
||||
days_left: 5,
|
||||
traffic_limit: "10 GB",
|
||||
traffic_limit_bytes: 10737418240,
|
||||
traffic_used: "0 B",
|
||||
traffic_used_bytes: 0,
|
||||
};
|
||||
DEV_MOCK.data.settings.trial_available = false;
|
||||
return { ok: true, activated: true, end_date_text: "05.05.2026 12:00" };
|
||||
}
|
||||
if (path === "/auth/logout") return { ok: true };
|
||||
if (path === "/account/language" && String(options.method || "").toUpperCase() === "POST") {
|
||||
let payload = {};
|
||||
@@ -1005,7 +1078,11 @@
|
||||
try {
|
||||
const response = await api("/payments", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ months: selectedPlan.months, method: selectedMethod }),
|
||||
body: JSON.stringify({
|
||||
months: selectedPlan.months,
|
||||
traffic_gb: selectedPlan.traffic_gb,
|
||||
method: selectedMethod,
|
||||
}),
|
||||
});
|
||||
if (!response.ok || !response.payment_url) throw response;
|
||||
showToast(t("wa_payment_created"));
|
||||
@@ -1084,6 +1161,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function activateTrial() {
|
||||
if (trialBusy) return;
|
||||
trialBusy = true;
|
||||
try {
|
||||
const response = await api("/trial/activate", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (!response.ok) throw response;
|
||||
showToast(t("wa_trial_activated"));
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
showToast(error?.message || t("wa_trial_activation_failed"));
|
||||
} finally {
|
||||
trialBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
markManualLogout();
|
||||
clearToken();
|
||||
@@ -1169,6 +1264,19 @@
|
||||
return `${formatted} ${symbol}`;
|
||||
}
|
||||
|
||||
function priceLabel(plan, methodId = selectedMethod) {
|
||||
if (String(methodId || "").toLowerCase().includes("stars") && Number(plan?.stars_price || 0) > 0) {
|
||||
return `${Number(plan.stars_price)} ⭐`;
|
||||
}
|
||||
return formatMoney(plan?.price || 0, plan?.currency);
|
||||
}
|
||||
|
||||
function formatTrafficGb(value) {
|
||||
const numeric = Number(value || 0);
|
||||
const formatted = Number.isInteger(numeric) ? String(numeric) : numeric.toFixed(2).replace(/0+$/, "").replace(/\.$/, "");
|
||||
return `${formatted} GB`;
|
||||
}
|
||||
|
||||
function trafficPercent(sub) {
|
||||
const used = Number(sub?.traffic_used_bytes || 0);
|
||||
const limit = Number(sub?.traffic_limit_bytes || 0);
|
||||
@@ -1202,6 +1310,9 @@
|
||||
}
|
||||
|
||||
function planDisplayTitle(plan) {
|
||||
if (trafficMode || plan?.sale_mode === "traffic") {
|
||||
return plan?.title || formatTrafficGb(plan?.traffic_gb || plan?.months);
|
||||
}
|
||||
const months = Number(plan?.months || 0);
|
||||
if (months === 12) {
|
||||
return t("wa_plan_one_year");
|
||||
@@ -1209,6 +1320,41 @@
|
||||
return plan?.title || "";
|
||||
}
|
||||
|
||||
function planUnitHint(plan) {
|
||||
if (trafficMode || plan?.sale_mode === "traffic") {
|
||||
const gb = Number(plan?.traffic_gb || plan?.months || 0);
|
||||
if (!gb) return "";
|
||||
if (String(selectedMethod || "").toLowerCase().includes("stars") && Number(plan?.stars_price || 0) > 0) {
|
||||
return `${Number(plan.stars_price / gb).toFixed(0)} ⭐${t("wa_per_gb_short")}`;
|
||||
}
|
||||
return `${formatMoney(Number(plan?.price || 0) / gb, plan?.currency)}${t("wa_per_gb_short")}`;
|
||||
}
|
||||
const months = Number(plan?.months || 0);
|
||||
if (!months || months <= 1) return "";
|
||||
if (String(selectedMethod || "").toLowerCase().includes("stars") && Number(plan?.stars_price || 0) > 0) {
|
||||
return `${Number(plan.stars_price / months).toFixed(0)} ⭐${t("wa_per_month_short")}`;
|
||||
}
|
||||
return `${formatMoney(Number(plan?.price || 0) / months, plan?.currency)}${t("wa_per_month_short")}`;
|
||||
}
|
||||
|
||||
function paymentTitle() {
|
||||
return trafficMode ? t("wa_traffic_packages_title") : t("wa_subscription_title");
|
||||
}
|
||||
|
||||
function paymentDescription() {
|
||||
return trafficMode ? t("wa_traffic_packages_choose") : t("wa_subscription_choose_period");
|
||||
}
|
||||
|
||||
function primaryPayActionLabel() {
|
||||
if (trafficMode) return t("wa_buy_traffic");
|
||||
return subscription.active ? t("wa_renew") : t("wa_pay_subscription");
|
||||
}
|
||||
|
||||
function trialTrafficLabel() {
|
||||
const limit = Number(appSettings?.trial_traffic_limit_gb || 0);
|
||||
return limit > 0 ? formatTrafficGb(limit) : t("wa_unlimited_traffic");
|
||||
}
|
||||
|
||||
function activeSubscriptionTermLabel(sub) {
|
||||
const forever = isForeverSubscription(sub);
|
||||
if (forever) return t("wa_sub_term_forever");
|
||||
@@ -1540,7 +1686,7 @@
|
||||
<div class="sub-status">
|
||||
<CheckCircle2 size={23} />
|
||||
<div>
|
||||
<h2>{t("wa_home_subscription_active")} | {activeSubscriptionTermLabel(subscription)}</h2>
|
||||
<h2>{trafficMode ? t("wa_home_access_active") : t("wa_home_subscription_active")} | {activeSubscriptionTermLabel(subscription)}</h2>
|
||||
<p>{subscription.end_date_text ? t("wa_until_date", { date: subscription.end_date_text }) : subscription.remaining_text}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1566,15 +1712,33 @@
|
||||
<span class="traffic-percent">{trafficPercent(subscription)}%</span>
|
||||
</div>
|
||||
</Card>
|
||||
{:else if appSettings?.trial_enabled && appSettings?.trial_available}
|
||||
<Card class="trial-card">
|
||||
<div class="trial-card-head">
|
||||
<Gift size={22} />
|
||||
<span>
|
||||
<strong>{t("wa_trial_title")}</strong>
|
||||
<small>{t("wa_trial_details", { days: Number(appSettings?.trial_duration_days || 0), traffic: trialTrafficLabel() })}</small>
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
<div class="action-stack">
|
||||
<Button class="wide" onclick={openPaymentModal}>
|
||||
{#if subscription.active}
|
||||
<RefreshCw size={18} />
|
||||
{:else if trafficMode}
|
||||
<Database size={18} />
|
||||
{/if}
|
||||
{subscription.active ? t("wa_renew") : t("wa_pay_subscription")}
|
||||
{primaryPayActionLabel()}
|
||||
</Button>
|
||||
{#if !subscription.active && appSettings?.trial_enabled && appSettings?.trial_available}
|
||||
<Button class="wide" variant="secondary" onclick={activateTrial} disabled={trialBusy}>
|
||||
<Gift size={18} />
|
||||
{t("wa_activate_trial")}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -1805,8 +1969,8 @@
|
||||
|
||||
<Dialog
|
||||
open={paymentModalOpen}
|
||||
title={t("wa_subscription_title")}
|
||||
description={t("wa_subscription_choose_period")}
|
||||
title={paymentTitle()}
|
||||
description={paymentDescription()}
|
||||
closeLabel={t("wa_close")}
|
||||
onclose={closePaymentModal}
|
||||
class="payment-dialog-card"
|
||||
@@ -1821,9 +1985,9 @@
|
||||
on:click={() => (selectedPlan = plan)}
|
||||
>
|
||||
<strong>{planDisplayTitle(plan)}</strong>
|
||||
<span>{formatMoney(plan.price, plan.currency)}</span>
|
||||
{#if plan.months > 1}
|
||||
<small>{formatMoney(plan.price / plan.months, plan.currency)}{t("wa_per_month_short")}</small>
|
||||
<span>{priceLabel(plan)}</span>
|
||||
{#if planUnitHint(plan)}
|
||||
<small>{planUnitHint(plan)}</small>
|
||||
{/if}
|
||||
{#if selectedPlan?.months === plan.months}
|
||||
<CheckCircle2 size={18} />
|
||||
@@ -1855,7 +2019,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
<Button class="wide bottom-action payment-submit-button" onclick={createPayment} disabled={!methods.length || payBusy}>
|
||||
{t("wa_pay")} {selectedPlan ? formatMoney(selectedPlan.price, selectedPlan.currency) : ""}
|
||||
{t("wa_pay")} {selectedPlan ? priceLabel(selectedPlan) : ""}
|
||||
<LockKeyhole size={17} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -359,6 +359,38 @@ a {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.trial-card {
|
||||
padding: 13px 14px;
|
||||
}
|
||||
|
||||
.trial-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
}
|
||||
|
||||
.trial-card-head > svg {
|
||||
flex: 0 0 auto;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.trial-card-head span {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.trial-card-head strong {
|
||||
font-size: 13px;
|
||||
line-height: 1.18;
|
||||
}
|
||||
|
||||
.trial-card-head small {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.28;
|
||||
}
|
||||
|
||||
.action-stack {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
|
||||
@@ -37,6 +37,7 @@ from bot.services.referral_service import ReferralService
|
||||
from bot.services.severpay_service import SeverPayService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.yookassa_service import YooKassaService
|
||||
from bot.utils.config_link import prepare_config_links
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
|
||||
from bot.utils.request_security import request_client_ip
|
||||
from config.settings import Settings
|
||||
@@ -99,6 +100,7 @@ class WebAppPaymentCreatePayload(BaseModel):
|
||||
|
||||
method: str = ""
|
||||
months: Any = None
|
||||
traffic_gb: Any = None
|
||||
description: Optional[constr(max_length=4096)] = None
|
||||
comment: Optional[constr(max_length=4096)] = None
|
||||
note: Optional[constr(max_length=4096)] = None
|
||||
@@ -182,6 +184,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
|
||||
app.router.add_post("/api/account/email/verify", account_email_verify_route)
|
||||
app.router.add_post("/api/account/telegram/link", account_telegram_link_route)
|
||||
app.router.add_post("/api/promo/apply", apply_promo_route)
|
||||
app.router.add_post("/api/trial/activate", activate_trial_route)
|
||||
app.router.add_post("/api/payments", create_payment_route)
|
||||
app.router.add_get("/api/payments/{payment_id}", payment_status_route)
|
||||
|
||||
@@ -430,6 +433,8 @@ def _get_cached_webapp_settings(request: web.Request) -> Dict[str, Any]:
|
||||
"logo_url": _resolve_webapp_logo_url(settings),
|
||||
"subscription_options": settings.subscription_options,
|
||||
"stars_subscription_options": settings.stars_subscription_options,
|
||||
"traffic_packages": settings.traffic_packages,
|
||||
"stars_traffic_packages": settings.stars_traffic_packages,
|
||||
"support_url": settings.SUPPORT_LINK or "",
|
||||
"terms_url": settings.TERMS_OF_SERVICE_URL or "",
|
||||
"privacy_policy_url": settings.PRIVACY_POLICY_URL or "",
|
||||
@@ -1352,19 +1357,46 @@ async def create_payment_route(request: web.Request) -> web.Response:
|
||||
if validation_error:
|
||||
return validation_error
|
||||
method = str(payment_payload.method or "").strip().lower()
|
||||
try:
|
||||
months = int(float(payment_payload.months))
|
||||
except (TypeError, ValueError):
|
||||
return _json_error(400, "invalid_plan", "Invalid subscription period")
|
||||
|
||||
settings: Settings = request.app["settings"]
|
||||
cached = _get_cached_webapp_settings(request)
|
||||
price = cached["subscription_options"].get(months)
|
||||
stars_price = cached["stars_subscription_options"].get(months)
|
||||
if price is None and method != "stars":
|
||||
return _json_error(400, "invalid_plan", "Subscription period is not available")
|
||||
if method == "stars" and (stars_price is None or int(stars_price) <= 0):
|
||||
return _json_error(400, "invalid_plan", "Stars price is not configured")
|
||||
traffic_mode = bool(settings.traffic_sale_mode)
|
||||
|
||||
if traffic_mode:
|
||||
try:
|
||||
traffic_gb = float(
|
||||
payment_payload.traffic_gb
|
||||
if payment_payload.traffic_gb is not None
|
||||
else payment_payload.months
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
return _json_error(400, "invalid_plan", "Invalid traffic package")
|
||||
if traffic_gb <= 0:
|
||||
return _json_error(400, "invalid_plan", "Invalid traffic package")
|
||||
package_key = _resolve_numeric_option_key(cached["traffic_packages"], traffic_gb)
|
||||
stars_package_key = _resolve_numeric_option_key(cached["stars_traffic_packages"], traffic_gb)
|
||||
price = cached["traffic_packages"].get(package_key) if package_key is not None else None
|
||||
stars_price = (
|
||||
cached["stars_traffic_packages"].get(stars_package_key)
|
||||
if stars_package_key is not None
|
||||
else None
|
||||
)
|
||||
if price is None and method != "stars":
|
||||
return _json_error(400, "invalid_plan", "Traffic package is not available")
|
||||
if method == "stars" and (stars_price is None or int(stars_price) <= 0):
|
||||
return _json_error(400, "invalid_plan", "Stars price is not configured")
|
||||
payment_units = int(traffic_gb) if float(traffic_gb).is_integer() else traffic_gb
|
||||
else:
|
||||
try:
|
||||
months = int(float(payment_payload.months))
|
||||
except (TypeError, ValueError):
|
||||
return _json_error(400, "invalid_plan", "Invalid subscription period")
|
||||
price = cached["subscription_options"].get(months)
|
||||
stars_price = cached["stars_subscription_options"].get(months)
|
||||
if price is None and method != "stars":
|
||||
return _json_error(400, "invalid_plan", "Subscription period is not available")
|
||||
if method == "stars" and (stars_price is None or int(stars_price) <= 0):
|
||||
return _json_error(400, "invalid_plan", "Stars price is not configured")
|
||||
payment_units = months
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
@@ -1377,10 +1409,81 @@ async def create_payment_route(request: web.Request) -> web.Response:
|
||||
session=session,
|
||||
user_id=user_id,
|
||||
method=method,
|
||||
months=months,
|
||||
months=payment_units,
|
||||
price=float(price or 0),
|
||||
stars_price=stars_price,
|
||||
lang=lang,
|
||||
sale_mode="traffic" if traffic_mode else "subscription",
|
||||
traffic_gb=float(payment_units) if traffic_mode else None,
|
||||
)
|
||||
|
||||
|
||||
async def activate_trial_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
rate_limit_response = await _enforce_webapp_rate_limit(
|
||||
request,
|
||||
user_id=user_id,
|
||||
action="trial_activate",
|
||||
)
|
||||
if rate_limit_response:
|
||||
return rate_limit_response
|
||||
|
||||
settings: Settings = request.app["settings"]
|
||||
if not settings.TRIAL_ENABLED or settings.TRIAL_DURATION_DAYS <= 0:
|
||||
return _json_error(400, "trial_unavailable", "Trial is not available")
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
subscription_service: SubscriptionService = request.app["subscription_service"]
|
||||
async with async_session_factory() as session:
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user or db_user.is_banned:
|
||||
return _json_error(403, "access_denied", "Access denied")
|
||||
|
||||
activation_result = await subscription_service.activate_trial_subscription(session, user_id)
|
||||
if not activation_result or not activation_result.get("activated"):
|
||||
await session.rollback()
|
||||
message_key = (
|
||||
activation_result.get("message_key", "trial_activation_failed")
|
||||
if activation_result
|
||||
else "trial_activation_failed"
|
||||
)
|
||||
status = 400 if message_key != "trial_activation_failed_panel_update" else 502
|
||||
return _json_error(status, message_key, message_key)
|
||||
|
||||
end_date = activation_result.get("end_date")
|
||||
config_link, connect_url = await prepare_config_links(
|
||||
settings,
|
||||
activation_result.get("subscription_url"),
|
||||
)
|
||||
|
||||
i18n_instance = request.app.get("i18n")
|
||||
if settings.LOG_TRIAL_ACTIVATIONS and i18n_instance:
|
||||
try:
|
||||
notification_service = NotificationService(request.app["bot"], settings, i18n_instance)
|
||||
await notification_service.notify_trial_activation(user_id, end_date)
|
||||
except Exception:
|
||||
logger.exception("Failed to send WebApp trial activation notification")
|
||||
|
||||
try:
|
||||
from db.dal import ad_dal as _ad_dal
|
||||
|
||||
await _ad_dal.mark_trial_activated(session, user_id)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logger.exception("Failed to mark WebApp trial activation for ad attribution")
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"activated": True,
|
||||
"days": activation_result.get("days", settings.TRIAL_DURATION_DAYS),
|
||||
"end_date": end_date.isoformat() if isinstance(end_date, datetime) else None,
|
||||
"end_date_text": _format_webapp_datetime(end_date) if isinstance(end_date, datetime) else None,
|
||||
"traffic_gb": activation_result.get("traffic_gb", settings.TRIAL_TRAFFIC_LIMIT_GB),
|
||||
"config_link": config_link,
|
||||
"connect_url": connect_url or config_link,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1984,6 +2087,11 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
||||
user_id,
|
||||
db_user.panel_user_uuid,
|
||||
) if db_user.panel_user_uuid else None
|
||||
trial_available = bool(
|
||||
settings.TRIAL_ENABLED
|
||||
and settings.TRIAL_DURATION_DAYS > 0
|
||||
and not await subscription_service.has_had_any_subscription(session, user_id)
|
||||
)
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception:
|
||||
@@ -2018,11 +2126,18 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
||||
lang,
|
||||
subscription_options=cached["subscription_options"],
|
||||
stars_subscription_options=cached["stars_subscription_options"],
|
||||
traffic_packages=cached["traffic_packages"],
|
||||
stars_traffic_packages=cached["stars_traffic_packages"],
|
||||
),
|
||||
"payment_methods": _serialize_payment_methods(settings, request.app),
|
||||
"settings": {
|
||||
"support_url": settings.SUPPORT_LINK,
|
||||
"traffic_mode": bool(settings.traffic_sale_mode),
|
||||
"trial_enabled": bool(settings.TRIAL_ENABLED),
|
||||
"trial_available": trial_available,
|
||||
"trial_duration_days": int(settings.TRIAL_DURATION_DAYS or 0),
|
||||
"trial_traffic_limit_gb": float(settings.TRIAL_TRAFFIC_LIMIT_GB or 0),
|
||||
"trial_traffic_strategy": settings.TRIAL_TRAFFIC_STRATEGY,
|
||||
"email_auth_enabled": settings.email_auth_configured,
|
||||
},
|
||||
}
|
||||
@@ -2120,7 +2235,33 @@ def _serialize_plans(
|
||||
*,
|
||||
subscription_options: Optional[Dict[int, float]] = None,
|
||||
stars_subscription_options: Optional[Dict[int, int]] = None,
|
||||
traffic_packages: Optional[Dict[float, float]] = None,
|
||||
stars_traffic_packages: Optional[Dict[float, int]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
if getattr(settings, "traffic_sale_mode", False):
|
||||
active_traffic_packages = traffic_packages or settings.traffic_packages
|
||||
active_stars_traffic_packages = stars_traffic_packages or settings.stars_traffic_packages
|
||||
traffic_units = sorted(set(active_traffic_packages) | set(active_stars_traffic_packages))
|
||||
plans: List[Dict[str, Any]] = []
|
||||
for traffic_gb in traffic_units:
|
||||
price = active_traffic_packages.get(traffic_gb)
|
||||
stars_price = active_stars_traffic_packages.get(traffic_gb)
|
||||
if price is None and (stars_price is None or int(stars_price) <= 0):
|
||||
continue
|
||||
traffic_value = float(traffic_gb)
|
||||
plan = {
|
||||
"months": int(traffic_value) if traffic_value.is_integer() else traffic_value,
|
||||
"traffic_gb": traffic_value,
|
||||
"price": float(price or 0),
|
||||
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
"title": _format_traffic_title(traffic_value, lang),
|
||||
"sale_mode": "traffic",
|
||||
}
|
||||
if stars_price is not None and int(stars_price) > 0:
|
||||
plan["stars_price"] = int(stars_price)
|
||||
plans.append(plan)
|
||||
return plans
|
||||
|
||||
active_subscription_options = subscription_options or settings.subscription_options
|
||||
active_stars_subscription_options = stars_subscription_options or settings.stars_subscription_options
|
||||
plans: List[Dict[str, Any]] = []
|
||||
@@ -2130,6 +2271,7 @@ def _serialize_plans(
|
||||
"price": float(price),
|
||||
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
"title": _format_months_title(int(months), lang),
|
||||
"sale_mode": "subscription",
|
||||
}
|
||||
stars_price = active_stars_subscription_options.get(months)
|
||||
if stars_price is not None and int(stars_price) > 0:
|
||||
@@ -2182,17 +2324,24 @@ async def _create_subscription_payment(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
method: str,
|
||||
months: int,
|
||||
months: Any,
|
||||
price: float,
|
||||
stars_price: Optional[int],
|
||||
lang: str,
|
||||
sale_mode: str = "subscription",
|
||||
traffic_gb: Optional[float] = None,
|
||||
) -> web.Response:
|
||||
settings: Settings = request.app["settings"]
|
||||
description = _payment_description(months, lang)
|
||||
sale_mode = "traffic" if sale_mode == "traffic" else "subscription"
|
||||
description = (
|
||||
_traffic_payment_description(float(traffic_gb if traffic_gb is not None else months), lang)
|
||||
if sale_mode == "traffic"
|
||||
else _payment_description(int(months), lang)
|
||||
)
|
||||
|
||||
if method == "yookassa":
|
||||
return await _create_yookassa_payment(
|
||||
request, session, user_id, months, price, description
|
||||
request, session, user_id, months, price, description, sale_mode=sale_mode, traffic_gb=traffic_gb
|
||||
)
|
||||
if method == "freekassa":
|
||||
return await _create_freekassa_payment(
|
||||
@@ -2200,7 +2349,7 @@ async def _create_subscription_payment(
|
||||
)
|
||||
if method in ("platega", "platega_sbp", "platega_crypto"):
|
||||
return await _create_platega_payment(
|
||||
request, session, user_id, months, price, description, variant=method
|
||||
request, session, user_id, months, price, description, variant=method, sale_mode=sale_mode, traffic_gb=traffic_gb
|
||||
)
|
||||
if method == "severpay":
|
||||
return await _create_severpay_payment(
|
||||
@@ -2216,7 +2365,7 @@ async def _create_subscription_payment(
|
||||
months=months,
|
||||
amount=price,
|
||||
description=description,
|
||||
sale_mode="subscription",
|
||||
sale_mode=sale_mode,
|
||||
url_kind="web",
|
||||
)
|
||||
if not url:
|
||||
@@ -2228,7 +2377,7 @@ async def _create_subscription_payment(
|
||||
if not settings.STARS_ENABLED or stars_price is None:
|
||||
return _json_error(400, "payment_unavailable", "Payment method unavailable")
|
||||
return await _create_stars_payment(
|
||||
request, session, user_id, months, int(stars_price), description
|
||||
request, session, user_id, months, int(stars_price), description, sale_mode=sale_mode
|
||||
)
|
||||
|
||||
return _json_error(400, "payment_unavailable", "Payment method unavailable")
|
||||
@@ -2265,9 +2414,12 @@ async def _create_yookassa_payment(
|
||||
request: web.Request,
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
months: int,
|
||||
months: Any,
|
||||
price: float,
|
||||
description: str,
|
||||
*,
|
||||
sale_mode: str = "subscription",
|
||||
traffic_gb: Optional[float] = None,
|
||||
) -> web.Response:
|
||||
settings: Settings = request.app["settings"]
|
||||
service: YooKassaService = request.app["yookassa_service"]
|
||||
@@ -2282,20 +2434,23 @@ async def _create_yookassa_payment(
|
||||
currency="RUB",
|
||||
status="pending_yookassa",
|
||||
description=description,
|
||||
months=months,
|
||||
months=int(float(months)) if sale_mode != "traffic" else int(float(traffic_gb or months)),
|
||||
provider="yookassa",
|
||||
)
|
||||
metadata = {
|
||||
"user_id": str(user_id),
|
||||
"subscription_months": str(int(float(months)) if sale_mode != "traffic" else 0),
|
||||
"payment_db_id": str(payment.payment_id),
|
||||
"sale_mode": sale_mode,
|
||||
"source": "webapp",
|
||||
}
|
||||
if sale_mode == "traffic":
|
||||
metadata["traffic_gb"] = _format_number_for_payload(traffic_gb or months)
|
||||
response = await service.create_payment(
|
||||
amount=price,
|
||||
currency="RUB",
|
||||
description=description,
|
||||
metadata={
|
||||
"user_id": str(user_id),
|
||||
"subscription_months": str(months),
|
||||
"payment_db_id": str(payment.payment_id),
|
||||
"sale_mode": "subscription",
|
||||
"source": "webapp",
|
||||
},
|
||||
metadata=metadata,
|
||||
receipt_email=settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL,
|
||||
save_payment_method=bool(
|
||||
settings.yookassa_autopayments_active
|
||||
@@ -2335,7 +2490,7 @@ async def _create_freekassa_payment(
|
||||
request: web.Request,
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
months: int,
|
||||
months: Any,
|
||||
price: float,
|
||||
description: str,
|
||||
) -> web.Response:
|
||||
@@ -2352,7 +2507,7 @@ async def _create_freekassa_payment(
|
||||
currency=service.default_currency,
|
||||
status="pending_freekassa",
|
||||
description=description,
|
||||
months=months,
|
||||
months=int(float(months)),
|
||||
provider="freekassa",
|
||||
)
|
||||
success, response_data = await service.create_order(
|
||||
@@ -2396,10 +2551,12 @@ async def _create_platega_payment(
|
||||
request: web.Request,
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
months: int,
|
||||
months: Any,
|
||||
price: float,
|
||||
description: str,
|
||||
variant: str = "platega_sbp",
|
||||
sale_mode: str = "subscription",
|
||||
traffic_gb: Optional[float] = None,
|
||||
) -> web.Response:
|
||||
settings: Settings = request.app["settings"]
|
||||
service: PlategaService = request.app["platega_service"]
|
||||
@@ -2422,15 +2579,17 @@ async def _create_platega_payment(
|
||||
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
status="pending_platega",
|
||||
description=description,
|
||||
months=months,
|
||||
months=int(float(months)) if sale_mode != "traffic" else int(float(traffic_gb or months)),
|
||||
provider="platega",
|
||||
)
|
||||
months_for_provider = int(float(months)) if sale_mode != "traffic" else int(float(traffic_gb or months))
|
||||
payload = json.dumps(
|
||||
{
|
||||
"payment_db_id": payment.payment_id,
|
||||
"user_id": user_id,
|
||||
"months": months,
|
||||
"sale_mode": "subscription",
|
||||
"months": months_for_provider if sale_mode != "traffic" else 0,
|
||||
"sale_mode": sale_mode,
|
||||
"traffic_gb": _format_number_for_payload(traffic_gb or months) if sale_mode == "traffic" else None,
|
||||
"source": "webapp",
|
||||
"platega_variant": "crypto" if variant == "platega_crypto" else "sbp",
|
||||
}
|
||||
@@ -2438,7 +2597,7 @@ async def _create_platega_payment(
|
||||
success, response_data = await service.create_transaction(
|
||||
payment_db_id=payment.payment_id,
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
months=months_for_provider,
|
||||
amount=price,
|
||||
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
description=description,
|
||||
@@ -2483,7 +2642,7 @@ async def _create_severpay_payment(
|
||||
request: web.Request,
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
months: int,
|
||||
months: Any,
|
||||
price: float,
|
||||
description: str,
|
||||
) -> web.Response:
|
||||
@@ -2500,7 +2659,7 @@ async def _create_severpay_payment(
|
||||
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
status="pending_severpay",
|
||||
description=description,
|
||||
months=months,
|
||||
months=int(float(months)),
|
||||
provider="severpay",
|
||||
)
|
||||
success, response_data = await service.create_payment(
|
||||
@@ -2546,9 +2705,10 @@ async def _create_stars_payment(
|
||||
request: web.Request,
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
months: int,
|
||||
months: Any,
|
||||
stars_price: int,
|
||||
description: str,
|
||||
sale_mode: str = "subscription",
|
||||
) -> web.Response:
|
||||
bot: Bot = request.app["bot"]
|
||||
try:
|
||||
@@ -2559,10 +2719,10 @@ async def _create_stars_payment(
|
||||
currency="XTR",
|
||||
status="pending_stars",
|
||||
description=description,
|
||||
months=months,
|
||||
months=int(float(months)),
|
||||
provider="telegram_stars",
|
||||
)
|
||||
payload = f"{payment.payment_id}:{months}:subscription"
|
||||
payload = f"{payment.payment_id}:{_format_number_for_payload(months)}:{sale_mode}"
|
||||
prices = [LabeledPrice(label=description, amount=stars_price)]
|
||||
create_invoice_link = getattr(bot, "create_invoice_link", None)
|
||||
if callable(create_invoice_link):
|
||||
@@ -2669,6 +2829,31 @@ def _format_months_title(months: int, lang: str) -> str:
|
||||
return f"{months} месяцев"
|
||||
|
||||
|
||||
def _format_number_for_payload(value: Any) -> str:
|
||||
numeric = float(value or 0)
|
||||
return str(int(numeric)) if numeric.is_integer() else f"{numeric:g}"
|
||||
|
||||
|
||||
def _format_traffic_title(traffic_gb: float, lang: str) -> str:
|
||||
return f"{_format_number_for_payload(traffic_gb)} GB"
|
||||
|
||||
|
||||
def _traffic_payment_description(traffic_gb: float, lang: str) -> str:
|
||||
if lang == "en":
|
||||
return f"Traffic package {_format_traffic_title(traffic_gb, lang)}"
|
||||
return f"Пакет трафика {_format_traffic_title(traffic_gb, lang)}"
|
||||
|
||||
|
||||
def _resolve_numeric_option_key(options: Dict[Any, Any], target: float) -> Optional[Any]:
|
||||
for key in options:
|
||||
try:
|
||||
if abs(float(key) - float(target)) < 0.000001:
|
||||
return key
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _payment_description(months: int, lang: str) -> str:
|
||||
if lang == "en":
|
||||
return f"Subscription for {_format_months_title(months, lang)}"
|
||||
|
||||
@@ -610,6 +610,7 @@
|
||||
"wa_auth_legal_and": "and",
|
||||
"wa_auth_legal_agreement": "user agreement",
|
||||
"wa_home_subscription_active": "Subscription active",
|
||||
"wa_home_access_active": "Access active",
|
||||
"wa_sub_term_forever": "Forever",
|
||||
"wa_sub_term_value_unit": "{value} {unit}",
|
||||
"wa_sub_term_day_one": "day",
|
||||
@@ -629,6 +630,15 @@
|
||||
"wa_subscription_title": "Subscription",
|
||||
"wa_subscription_choose_period": "Choose subscription period",
|
||||
"wa_per_month_short": "/mo",
|
||||
"wa_traffic_packages_title": "Traffic",
|
||||
"wa_traffic_packages_choose": "Choose traffic package",
|
||||
"wa_per_gb_short": "/GB",
|
||||
"wa_buy_traffic": "Buy traffic",
|
||||
"wa_activate_trial": "Activate trial",
|
||||
"wa_trial_title": "Trial period",
|
||||
"wa_trial_details": "{days} days, {traffic}",
|
||||
"wa_trial_activated": "Trial activated",
|
||||
"wa_trial_activation_failed": "Failed to activate trial",
|
||||
"wa_payment_methods_not_configured": "Payment methods are not configured yet",
|
||||
"wa_pay": "Pay",
|
||||
"wa_referral_link_title": "Your referral link",
|
||||
|
||||
@@ -610,6 +610,7 @@
|
||||
"wa_auth_legal_and": "и",
|
||||
"wa_auth_legal_agreement": "пользовательским соглашением",
|
||||
"wa_home_subscription_active": "Подписка активна",
|
||||
"wa_home_access_active": "Доступ активен",
|
||||
"wa_sub_term_forever": "Навсегда",
|
||||
"wa_sub_term_value_unit": "{value} {unit}",
|
||||
"wa_sub_term_day_one": "день",
|
||||
@@ -629,6 +630,15 @@
|
||||
"wa_subscription_title": "Подписка",
|
||||
"wa_subscription_choose_period": "Выберите срок подписки",
|
||||
"wa_per_month_short": "/мес",
|
||||
"wa_traffic_packages_title": "Трафик",
|
||||
"wa_traffic_packages_choose": "Выберите пакет трафика",
|
||||
"wa_per_gb_short": "/ГБ",
|
||||
"wa_buy_traffic": "Купить трафик",
|
||||
"wa_activate_trial": "Активировать триал",
|
||||
"wa_trial_title": "Пробный период",
|
||||
"wa_trial_details": "{days} дн., {traffic}",
|
||||
"wa_trial_activated": "Пробный период активирован",
|
||||
"wa_trial_activation_failed": "Не удалось активировать пробный период",
|
||||
"wa_payment_methods_not_configured": "Способы оплаты пока не настроены",
|
||||
"wa_pay": "Оплатить",
|
||||
"wa_referral_link_title": "Ваша реферальная ссылка",
|
||||
|
||||
@@ -6,9 +6,27 @@ from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from bot.app.web import subscription_webapp
|
||||
from config.settings import Settings
|
||||
|
||||
|
||||
class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
|
||||
def test_serialize_plans_uses_traffic_packages_in_traffic_mode(self):
|
||||
settings = Settings(
|
||||
_env_file=None,
|
||||
BOT_TOKEN="token",
|
||||
POSTGRES_USER="app_user",
|
||||
POSTGRES_PASSWORD="app_password",
|
||||
TRAFFIC_PACKAGES="10:199,50:799",
|
||||
STARS_TRAFFIC_PACKAGES="50:2500",
|
||||
)
|
||||
|
||||
plans = subscription_webapp._serialize_plans(settings, "en")
|
||||
|
||||
self.assertEqual([plan["traffic_gb"] for plan in plans], [10.0, 50.0])
|
||||
self.assertEqual(plans[0]["sale_mode"], "traffic")
|
||||
self.assertEqual(plans[0]["price"], 199.0)
|
||||
self.assertEqual(plans[1]["stars_price"], 2500)
|
||||
|
||||
def test_resolve_webapp_js_asset_name_prefers_latest_minified_build(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
asset_dir = Path(tmpdir)
|
||||
|
||||
Reference in New Issue
Block a user