diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py
index 1a446032..1b4a43ae 100644
--- a/app/webapi/routes/miniapp.py
+++ b/app/webapi/routes/miniapp.py
@@ -9,6 +9,8 @@ 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.services.remnawave_service import (
@@ -26,7 +28,9 @@ from ..dependencies import get_db_session
from ..schemas.miniapp import (
MiniAppConnectedServer,
MiniAppDevice,
+ MiniAppAutoPromoGroupLevel,
MiniAppPromoGroup,
+ MiniAppPromoGroupDiscounts,
MiniAppSubscriptionRequest,
MiniAppSubscriptionResponse,
MiniAppSubscriptionUser,
@@ -336,6 +340,53 @@ 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
+
+ period_discounts_map: Dict[int, int] = {}
+ normalized_periods: List[Tuple[int, int]] = []
+ if isinstance(group.period_discounts, dict):
+ for raw_days, raw_percent in group.period_discounts.items():
+ try:
+ days = int(raw_days)
+ percent = int(raw_percent)
+ except (TypeError, ValueError):
+ continue
+
+ normalized_percent = max(0, min(100, percent))
+ if normalized_percent > 0 and days > 0:
+ normalized_periods.append((days, normalized_percent))
+
+ if normalized_periods:
+ normalized_periods.sort(key=lambda item: item[0])
+ period_discounts_map = {days: percent for days, percent in normalized_periods}
+
+ discounts = MiniAppPromoGroupDiscounts(
+ servers_percent=max(0, group.server_discount_percent or 0),
+ traffic_percent=max(0, group.traffic_discount_percent or 0),
+ devices_percent=max(0, group.device_discount_percent or 0),
+ period_discounts=period_discounts_map,
+ applies_to_addons=bool(group.apply_discounts_to_addons),
+ )
+
+ 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),
+ discounts=discounts,
+ )
+ )
response_user = MiniAppSubscriptionUser(
telegram_id=user.telegram_id,
@@ -389,6 +440,10 @@ async def get_subscription_details(
promo_group=MiniAppPromoGroup(id=promo_group.id, name=promo_group.name)
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(),
diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py
index 3461e170..449f88d8 100644
--- a/app/webapi/schemas/miniapp.py
+++ b/app/webapi/schemas/miniapp.py
@@ -41,6 +41,27 @@ class MiniAppPromoGroup(BaseModel):
name: str
+class MiniAppPromoGroupDiscounts(BaseModel):
+ servers_percent: int = 0
+ traffic_percent: int = 0
+ devices_percent: int = 0
+ period_discounts: Dict[int, int] = Field(default_factory=dict)
+ applies_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
+ discounts: MiniAppPromoGroupDiscounts = Field(
+ default_factory=MiniAppPromoGroupDiscounts
+ )
+
+
class MiniAppConnectedServer(BaseModel):
uuid: str
name: str
@@ -90,6 +111,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..c2127a3d 100644
--- a/miniapp/index.html
+++ b/miniapp/index.html
@@ -576,6 +576,139 @@
text-align: right;
}
+ /* Promo levels */
+ .promo-levels-card .card-content {
+ opacity: 1;
+ max-height: 2000px;
+ padding-bottom: 16px;
+ }
+
+ .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: flex-start;
+ 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 {
+ margin-top: 6px;
+ list-style: none;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ }
+
+ .promo-level-discount-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ font-size: 12px;
+ color: var(--text-secondary);
+ }
+
+ .promo-level-discount-label {
+ font-weight: 600;
+ }
+
+ .promo-level-discount-value {
+ padding: 2px 8px;
+ border-radius: var(--radius-sm);
+ background: rgba(var(--primary-rgb), 0.12);
+ color: var(--text-primary);
+ font-weight: 600;
+ letter-spacing: 0.2px;
+ }
+
+ .promo-level-discount-item.is-empty {
+ justify-content: flex-start;
+ font-style: italic;
+ padding: 0;
+ }
+
+ .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);
+ }
+
/* Balance Card */
.balance-card {
background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.1), rgba(var(--primary-rgb), 0.05));
@@ -1380,6 +1513,10 @@
Type
-
+
+ Promo group
+ -
+
Device Limit
-
@@ -1391,6 +1528,28 @@
+
+
+