feat: support traffic and trial flow in web app

This commit is contained in:
3252a8
2026-04-30 07:34:29 +03:00
parent 2df4d2d770
commit 3d5998b8fb
6 changed files with 468 additions and 49 deletions
+224 -39
View File
@@ -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)}"