diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py
index 1a446032..09a130d1 100644
--- a/app/webapi/routes/miniapp.py
+++ b/app/webapi/routes/miniapp.py
@@ -9,8 +9,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.server_squad import get_server_squad_by_uuid
+from app.database.crud.promo_group import get_auto_assign_promo_groups
+from app.database.crud.transaction import get_user_total_spent_kopeks
from app.database.crud.user import get_user_by_telegram_id
-from app.database.models import Subscription, Transaction, User
+from app.database.models import PromoGroup, Subscription, Transaction, User
from app.services.remnawave_service import (
RemnaWaveConfigurationError,
RemnaWaveService,
@@ -26,6 +28,7 @@ from ..dependencies import get_db_session
from ..schemas.miniapp import (
MiniAppConnectedServer,
MiniAppDevice,
+ MiniAppAutoPromoGroupLevel,
MiniAppPromoGroup,
MiniAppSubscriptionRequest,
MiniAppSubscriptionResponse,
@@ -336,6 +339,27 @@ async def get_subscription_details(
balance_currency = balance_currency.upper()
promo_group = getattr(user, "promo_group", None)
+ total_spent_kopeks = await get_user_total_spent_kopeks(db, user.id)
+ auto_assign_groups = await get_auto_assign_promo_groups(db)
+
+ auto_promo_levels: List[MiniAppAutoPromoGroupLevel] = []
+ for group in auto_assign_groups:
+ threshold = group.auto_assign_total_spent_kopeks or 0
+ if threshold <= 0:
+ continue
+
+ auto_promo_levels.append(
+ MiniAppAutoPromoGroupLevel(
+ id=group.id,
+ name=group.name,
+ threshold_kopeks=threshold,
+ threshold_rubles=round(threshold / 100, 2),
+ threshold_label=settings.format_price(threshold),
+ is_reached=total_spent_kopeks >= threshold,
+ is_current=bool(promo_group and promo_group.id == group.id),
+ **_extract_promo_discounts(group),
+ )
+ )
response_user = MiniAppSubscriptionUser(
telegram_id=user.telegram_id,
@@ -386,11 +410,66 @@ async def get_subscription_details(
balance_rubles=round(user.balance_rubles, 2),
balance_currency=balance_currency,
transactions=[_serialize_transaction(tx) for tx in transactions],
- promo_group=MiniAppPromoGroup(id=promo_group.id, name=promo_group.name)
- if promo_group
- else None,
+ promo_group=(
+ MiniAppPromoGroup(
+ id=promo_group.id,
+ name=promo_group.name,
+ **_extract_promo_discounts(promo_group),
+ )
+ if promo_group
+ else None
+ ),
+ auto_assign_promo_groups=auto_promo_levels,
+ total_spent_kopeks=total_spent_kopeks,
+ total_spent_rubles=round(total_spent_kopeks / 100, 2),
+ total_spent_label=settings.format_price(total_spent_kopeks),
subscription_type="trial" if subscription.is_trial else "paid",
autopay_enabled=bool(subscription.autopay_enabled),
branding=settings.get_miniapp_branding(),
)
+def _safe_int(value: Any) -> int:
+ try:
+ return int(value)
+ except (TypeError, ValueError):
+ return 0
+
+
+def _normalize_period_discounts(
+ raw: Optional[Dict[Any, Any]]
+) -> Dict[int, int]:
+ if not isinstance(raw, dict):
+ return {}
+
+ normalized: Dict[int, int] = {}
+ for key, value in raw.items():
+ try:
+ period = int(key)
+ normalized[period] = int(value)
+ except (TypeError, ValueError):
+ continue
+
+ return normalized
+
+
+def _extract_promo_discounts(group: Optional[PromoGroup]) -> Dict[str, Any]:
+ if not group:
+ return {
+ "server_discount_percent": 0,
+ "traffic_discount_percent": 0,
+ "device_discount_percent": 0,
+ "period_discounts": {},
+ "apply_discounts_to_addons": True,
+ }
+
+ return {
+ "server_discount_percent": max(0, _safe_int(getattr(group, "server_discount_percent", 0))),
+ "traffic_discount_percent": max(0, _safe_int(getattr(group, "traffic_discount_percent", 0))),
+ "device_discount_percent": max(0, _safe_int(getattr(group, "device_discount_percent", 0))),
+ "period_discounts": _normalize_period_discounts(getattr(group, "period_discounts", None)),
+ "apply_discounts_to_addons": bool(
+ getattr(group, "apply_discounts_to_addons", True)
+ ),
+ }
+
+
diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py
index 3461e170..360e6fa5 100644
--- a/app/webapi/schemas/miniapp.py
+++ b/app/webapi/schemas/miniapp.py
@@ -39,6 +39,26 @@ class MiniAppSubscriptionUser(BaseModel):
class MiniAppPromoGroup(BaseModel):
id: int
name: str
+ server_discount_percent: int = 0
+ traffic_discount_percent: int = 0
+ device_discount_percent: int = 0
+ period_discounts: Dict[int, int] = Field(default_factory=dict)
+ apply_discounts_to_addons: bool = True
+
+
+class MiniAppAutoPromoGroupLevel(BaseModel):
+ id: int
+ name: str
+ threshold_kopeks: int
+ threshold_rubles: float
+ threshold_label: str
+ is_reached: bool = False
+ is_current: bool = False
+ server_discount_percent: int = 0
+ traffic_discount_percent: int = 0
+ device_discount_percent: int = 0
+ period_discounts: Dict[int, int] = Field(default_factory=dict)
+ apply_discounts_to_addons: bool = True
class MiniAppConnectedServer(BaseModel):
@@ -90,6 +110,10 @@ class MiniAppSubscriptionResponse(BaseModel):
balance_currency: Optional[str] = None
transactions: List[MiniAppTransaction] = Field(default_factory=list)
promo_group: Optional[MiniAppPromoGroup] = None
+ auto_assign_promo_groups: List[MiniAppAutoPromoGroupLevel] = Field(default_factory=list)
+ total_spent_kopeks: int = 0
+ total_spent_rubles: float = 0.0
+ total_spent_label: Optional[str] = None
subscription_type: str
autopay_enabled: bool = False
branding: Optional[MiniAppBranding] = None
diff --git a/miniapp/index.html b/miniapp/index.html
index 29d0c127..dd12503f 100644
--- a/miniapp/index.html
+++ b/miniapp/index.html
@@ -560,6 +560,10 @@
border-radius: var(--radius-sm);
}
+ .info-item.promo-group-info {
+ align-items: flex-start;
+ }
+
.info-label {
font-size: 14px;
color: var(--text-secondary);
@@ -576,6 +580,311 @@
text-align: right;
}
+ .promo-group-value {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 8px;
+ width: 100%;
+ }
+
+ .promo-group-chip {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 6px 12px;
+ border-radius: 999px;
+ background: rgba(var(--primary-rgb), 0.12);
+ color: var(--primary);
+ font-size: 13px;
+ font-weight: 600;
+ border: 1px solid rgba(var(--primary-rgb), 0.16);
+ min-width: 140px;
+ text-align: center;
+ }
+
+ .promo-group-chip.muted {
+ color: var(--text-secondary);
+ background: rgba(var(--primary-rgb), 0.05);
+ border-color: rgba(var(--primary-rgb), 0.1);
+ }
+
+ .promo-group-discounts,
+ .promo-discount-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ justify-content: flex-end;
+ }
+
+ .promo-group-period-discounts,
+ .promo-period-discounts {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ align-items: flex-end;
+ }
+
+ .promo-periods-title {
+ font-size: 12px;
+ text-transform: uppercase;
+ letter-spacing: 0.4px;
+ color: var(--text-secondary);
+ }
+
+ .promo-group-period-discounts .promo-periods-title {
+ width: 100%;
+ text-align: right;
+ }
+
+ .promo-period-badges {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ justify-content: flex-end;
+ }
+
+ .promo-period-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 10px;
+ border-radius: 999px;
+ background: rgba(var(--primary-rgb), 0.08);
+ border: 1px solid rgba(var(--primary-rgb), 0.12);
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-secondary);
+ white-space: nowrap;
+ }
+
+ .promo-period-badge .promo-period-value {
+ color: var(--primary);
+ }
+
+ .promo-group-note {
+ font-size: 12px;
+ color: var(--warning);
+ background: rgba(245, 158, 11, 0.08);
+ padding: 6px 10px;
+ border-radius: var(--radius-sm);
+ border: 1px solid rgba(245, 158, 11, 0.25);
+ max-width: 260px;
+ text-align: right;
+ }
+
+ .promo-periods-empty {
+ font-size: 12px;
+ color: var(--text-secondary);
+ text-align: right;
+ }
+
+ /* Promo levels */
+ .promo-levels-card.expandable .card-content {
+ padding-bottom: 0;
+ }
+
+ .promo-levels-card.expanded .card-content {
+ padding-bottom: 16px;
+ }
+
+ .promo-levels-card .card-header {
+ align-items: flex-start;
+ }
+
+ .promo-levels-card .card-header .expand-icon {
+ margin-top: 4px;
+ }
+
+ .promo-header-content {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ flex: 1;
+ }
+
+ .promo-levels-header-meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ }
+
+ .promo-levels-header-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 10px;
+ border-radius: 999px;
+ background: rgba(var(--primary-rgb), 0.08);
+ border: 1px solid rgba(var(--primary-rgb), 0.12);
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-primary);
+ }
+
+ .promo-levels-header-chip.muted {
+ color: var(--text-secondary);
+ background: rgba(var(--primary-rgb), 0.05);
+ }
+
+ .promo-level-summary {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0 20px 12px;
+ font-weight: 600;
+ color: var(--text-primary);
+ }
+
+ .promo-level-summary {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0 20px 12px;
+ font-weight: 600;
+ color: var(--text-primary);
+ }
+
+ .promo-level-summary-label {
+ color: var(--text-secondary);
+ font-size: 13px;
+ }
+
+ .promo-level-summary-value {
+ font-size: 16px;
+ }
+
+ .promo-level-list {
+ list-style: none;
+ padding: 0 20px 8px;
+ margin: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ }
+
+ .promo-level-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 14px 16px;
+ border-radius: var(--radius);
+ border: 1px solid var(--border-color);
+ background: var(--bg-secondary);
+ transition: border-color 0.3s ease, box-shadow 0.3s ease, transform 0.3s ease;
+ }
+
+ .promo-level-item:hover {
+ border-color: rgba(var(--primary-rgb), 0.4);
+ box-shadow: var(--shadow-sm);
+ transform: translateY(-1px);
+ }
+
+ .promo-level-item.current {
+ border-color: var(--primary);
+ box-shadow: var(--shadow-sm);
+ }
+
+ .promo-level-info {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ }
+
+ .promo-level-discounts {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin-top: 8px;
+ }
+
+ .promo-period-discounts .promo-periods-title {
+ align-self: flex-start;
+ }
+
+ .promo-level-name {
+ font-weight: 700;
+ color: var(--text-primary);
+ font-size: 15px;
+ }
+
+ .promo-level-threshold {
+ font-size: 13px;
+ color: var(--text-secondary);
+ }
+
+ .promo-level-badge {
+ padding: 6px 12px;
+ border-radius: 999px;
+ font-size: 12px;
+ font-weight: 600;
+ letter-spacing: 0.3px;
+ }
+
+ .promo-level-item.current .promo-level-badge {
+ background: rgba(var(--primary-rgb), 0.18);
+ color: var(--primary);
+ }
+
+ .promo-level-item.reached .promo-level-badge {
+ background: rgba(16, 185, 129, 0.18);
+ color: var(--success);
+ }
+
+ .promo-level-item.locked .promo-level-badge {
+ background: rgba(148, 163, 184, 0.18);
+ color: var(--text-secondary);
+ }
+
+ .promo-discount-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px 8px;
+ border-radius: 999px;
+ border: 1px solid rgba(var(--primary-rgb), 0.12);
+ background: rgba(var(--primary-rgb), 0.08);
+ color: var(--text-secondary);
+ font-size: 12px;
+ font-weight: 600;
+ white-space: nowrap;
+ }
+
+ .promo-discount-badge .promo-discount-value {
+ color: var(--text-primary);
+ }
+
+ .promo-discount-badge.muted {
+ background: rgba(var(--primary-rgb), 0.05);
+ border-color: rgba(var(--primary-rgb), 0.08);
+ }
+
+ .promo-discount-badge.muted .promo-discount-value {
+ color: var(--text-secondary);
+ }
+
+ .promo-level-item.current .promo-discount-badge {
+ border-color: rgba(var(--primary-rgb), 0.35);
+ color: var(--primary);
+ }
+
+ .promo-level-item.current .promo-discount-badge .promo-discount-value {
+ color: var(--primary);
+ }
+
+ .promo-level-item.reached .promo-discount-badge {
+ border-color: rgba(16, 185, 129, 0.35);
+ color: var(--success);
+ }
+
+ .promo-level-item.reached .promo-discount-badge .promo-discount-value {
+ color: var(--success);
+ }
+
+ .promo-level-item.locked .promo-discount-badge {
+ opacity: 0.8;
+ }
+
/* Balance Card */
.balance-card {
background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.1), rgba(var(--primary-rgb), 0.05));
@@ -1242,6 +1551,45 @@
color: var(--text-secondary);
}
+ :root[data-theme="dark"] .promo-discount-badge {
+ background: rgba(var(--primary-rgb), 0.12);
+ border-color: rgba(var(--primary-rgb), 0.2);
+ }
+
+ :root[data-theme="dark"] .promo-discount-badge.muted {
+ background: rgba(148, 163, 184, 0.15);
+ border-color: rgba(148, 163, 184, 0.25);
+ color: rgba(226, 232, 240, 0.75);
+ }
+
+ :root[data-theme="dark"] .promo-discount-badge.muted .promo-discount-value {
+ color: rgba(226, 232, 240, 0.75);
+ }
+
+ :root[data-theme="dark"] .promo-group-chip,
+ :root[data-theme="dark"] .promo-levels-header-chip,
+ :root[data-theme="dark"] .promo-period-badge {
+ background: rgba(var(--primary-rgb), 0.16);
+ border-color: rgba(var(--primary-rgb), 0.28);
+ color: var(--text-primary);
+ }
+
+ :root[data-theme="dark"] .promo-group-chip.muted,
+ :root[data-theme="dark"] .promo-levels-header-chip.muted {
+ background: rgba(148, 163, 184, 0.18);
+ border-color: rgba(148, 163, 184, 0.28);
+ color: var(--text-secondary);
+ }
+
+ :root[data-theme="dark"] .promo-period-badge .promo-period-value {
+ color: var(--primary);
+ }
+
+ :root[data-theme="dark"] .promo-group-note {
+ background: rgba(245, 158, 11, 0.18);
+ border-color: rgba(245, 158, 11, 0.45);
+ }
+
:root[data-theme="dark"] body,
:root[data-theme="dark"] .card,
:root[data-theme="dark"] .user-card,
@@ -1380,6 +1728,15 @@
Type
-
+
Device Limit
-
@@ -1391,6 +1748,38 @@
+
+
+