From 6803c7801f06b81807a3e6142160ce0c0f434eb2 Mon Sep 17 00:00:00 2001
From: 3252a8 <3252a8@proton.me>
Date: Fri, 29 May 2026 22:30:31 +0300
Subject: [PATCH] fix: configure referral bonuses per tariff period
---
.../bot/app/web/admin_settings_manifest.py | 120 ++++++++++--------
backend/bot/app/web/webapp/serializers.py | 39 +++++-
.../bot/payment_providers/shared/success.py | 9 +-
backend/bot/payment_providers/yookassa.py | 4 +
backend/bot/services/referral_service.py | 38 +++++-
.../subscription_service_impl/lifecycle.py | 8 +-
backend/config/settings.py | 8 +-
backend/config/tariffs_config.py | 37 +++++-
data/tariffs.example.json | 4 +-
.../admin/sections/TariffEditorModal.svelte | 32 ++++-
.../src/admin/sections/TariffsSection.svelte | 56 +++++++-
frontend/src/lib/admin/tariffDraft.js | 22 +++-
frontend/src/lib/webapp/mockApi.js | 16 ++-
frontend/src/styles/admin.css | 13 +-
.../src/webapp/screens/InviteScreen.svelte | 2 +-
locales/en.json | 6 +
locales/ru.json | 6 +
tests/test_admin_settings_manifest_i18n.py | 2 +
tests/test_referral_bonuses.py | 82 ++++++++++++
tests/test_settings.py | 14 ++
tests/test_subscription_service_behavior.py | 59 +++++++++
tests/test_tariffs_config.py | 22 ++++
tests/test_webapp_assets.py | 95 ++++++++++++++
23 files changed, 604 insertions(+), 90 deletions(-)
diff --git a/backend/bot/app/web/admin_settings_manifest.py b/backend/bot/app/web/admin_settings_manifest.py
index fb2ebde..86e0b26 100644
--- a/backend/bot/app/web/admin_settings_manifest.py
+++ b/backend/bot/app/web/admin_settings_manifest.py
@@ -230,6 +230,70 @@ SETTINGS_MANIFEST: List[SettingField] = [
SettingField("STARS_PRICE_3_MONTHS", "int", "pricing", "Цена 3 мес. (Stars)"),
SettingField("STARS_PRICE_6_MONTHS", "int", "pricing", "Цена 6 мес. (Stars)"),
SettingField("STARS_PRICE_12_MONTHS", "int", "pricing", "Цена 12 мес. (Stars)"),
+ SettingField(
+ "REFERRAL_BONUS_DAYS_INVITER_1_MONTH",
+ "int",
+ "pricing",
+ "Бонус приглашающему: 1 мес.",
+ min=0,
+ subsection="legacy_tariffs",
+ ),
+ SettingField(
+ "REFERRAL_BONUS_DAYS_INVITER_3_MONTHS",
+ "int",
+ "pricing",
+ "Бонус приглашающему: 3 мес.",
+ min=0,
+ subsection="legacy_tariffs",
+ ),
+ SettingField(
+ "REFERRAL_BONUS_DAYS_INVITER_6_MONTHS",
+ "int",
+ "pricing",
+ "Бонус приглашающему: 6 мес.",
+ min=0,
+ subsection="legacy_tariffs",
+ ),
+ SettingField(
+ "REFERRAL_BONUS_DAYS_INVITER_12_MONTHS",
+ "int",
+ "pricing",
+ "Бонус приглашающему: 12 мес.",
+ min=0,
+ subsection="legacy_tariffs",
+ ),
+ SettingField(
+ "REFERRAL_BONUS_DAYS_REFEREE_1_MONTH",
+ "int",
+ "pricing",
+ "Бонус приглашённому: 1 мес.",
+ min=0,
+ subsection="legacy_tariffs",
+ ),
+ SettingField(
+ "REFERRAL_BONUS_DAYS_REFEREE_3_MONTHS",
+ "int",
+ "pricing",
+ "Бонус приглашённому: 3 мес.",
+ min=0,
+ subsection="legacy_tariffs",
+ ),
+ SettingField(
+ "REFERRAL_BONUS_DAYS_REFEREE_6_MONTHS",
+ "int",
+ "pricing",
+ "Бонус приглашённому: 6 мес.",
+ min=0,
+ subsection="legacy_tariffs",
+ ),
+ SettingField(
+ "REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS",
+ "int",
+ "pricing",
+ "Бонус приглашённому: 12 мес.",
+ min=0,
+ subsection="legacy_tariffs",
+ ),
SettingField(
"TRAFFIC_PACKAGES", "string", "pricing", "Пакеты трафика", "Формат: 10:199,50:799 (ГБ:цена)"
),
@@ -333,62 +397,6 @@ SETTINGS_MANIFEST: List[SettingField] = [
"REFERRAL_WELCOME_BONUS_DAYS", "int", "referral", "Приветственный бонус (дней)", min=0
),
SettingField("LEGACY_REFS", "bool", "referral", "Поддержка старых ref-ссылок"),
- SettingField(
- "REFERRAL_BONUS_DAYS_INVITER_1_MONTH",
- "int",
- "referral",
- "Бонус приглашающему: 1 мес.",
- min=0,
- ),
- SettingField(
- "REFERRAL_BONUS_DAYS_INVITER_3_MONTHS",
- "int",
- "referral",
- "Бонус приглашающему: 3 мес.",
- min=0,
- ),
- SettingField(
- "REFERRAL_BONUS_DAYS_INVITER_6_MONTHS",
- "int",
- "referral",
- "Бонус приглашающему: 6 мес.",
- min=0,
- ),
- SettingField(
- "REFERRAL_BONUS_DAYS_INVITER_12_MONTHS",
- "int",
- "referral",
- "Бонус приглашающему: 12 мес.",
- min=0,
- ),
- SettingField(
- "REFERRAL_BONUS_DAYS_REFEREE_1_MONTH",
- "int",
- "referral",
- "Бонус приглашённому: 1 мес.",
- min=0,
- ),
- SettingField(
- "REFERRAL_BONUS_DAYS_REFEREE_3_MONTHS",
- "int",
- "referral",
- "Бонус приглашённому: 3 мес.",
- min=0,
- ),
- SettingField(
- "REFERRAL_BONUS_DAYS_REFEREE_6_MONTHS",
- "int",
- "referral",
- "Бонус приглашённому: 6 мес.",
- min=0,
- ),
- SettingField(
- "REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS",
- "int",
- "referral",
- "Бонус приглашённому: 12 мес.",
- min=0,
- ),
# ─── Notifications ─────────────────────────────────────────────
SettingField(
"SUBSCRIPTION_NOTIFICATIONS_ENABLED",
diff --git a/backend/bot/app/web/webapp/serializers.py b/backend/bot/app/web/webapp/serializers.py
index 867a127..55dbc7e 100644
--- a/backend/bot/app/web/webapp/serializers.py
+++ b/backend/bot/app/web/webapp/serializers.py
@@ -156,12 +156,47 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
}
-def _serialize_referral_bonus_details(settings: Settings, lang: str) -> List[Dict[str, Any]]:
+def _legacy_referral_bonus_periods(settings: Settings) -> List[int]:
if getattr(settings, "traffic_sale_mode", False):
return []
+ return sorted(int(months) for months in settings.subscription_options)
+
+
+def _serialize_tariff_referral_bonus_details(settings: Settings, lang: str) -> List[Dict[str, Any]]:
+ tariffs_config = settings.tariffs_config
+ if not tariffs_config:
+ return []
+
details: List[Dict[str, Any]] = []
- for months, _price in sorted(settings.subscription_options.items()):
+ for tariff in tariffs_config.enabled_tariffs:
+ if tariff.billing_model != "period":
+ continue
+ for months in sorted(int(month) for month in tariff.enabled_periods):
+ inviter_days = tariff.referral_inviter_bonus_days(months)
+ friend_days = tariff.referral_referee_bonus_days(months)
+ if inviter_days is None and friend_days is None:
+ continue
+ details.append(
+ {
+ "id": f"{tariff.key}:{months}",
+ "tariff_key": tariff.key,
+ "tariff_name": tariff.name(lang),
+ "months": int(months),
+ "title": f"{tariff.name(lang)} - {_format_months_title(int(months), lang)}",
+ "inviter_days": int(inviter_days or 0),
+ "friend_days": int(friend_days or 0),
+ }
+ )
+ return details
+
+
+def _serialize_referral_bonus_details(settings: Settings, lang: str) -> List[Dict[str, Any]]:
+ if settings.tariffs_config:
+ return _serialize_tariff_referral_bonus_details(settings, lang)
+
+ details: List[Dict[str, Any]] = []
+ for months in _legacy_referral_bonus_periods(settings):
inviter_days = settings.referral_bonus_inviter.get(months)
friend_days = settings.referral_bonus_referee.get(months)
if inviter_days is None and friend_days is None:
diff --git a/backend/bot/payment_providers/shared/success.py b/backend/bot/payment_providers/shared/success.py
index d78c0fe..26e342a 100644
--- a/backend/bot/payment_providers/shared/success.py
+++ b/backend/bot/payment_providers/shared/success.py
@@ -16,7 +16,13 @@ from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
from db.dal import payment_dal, user_dal
from db.models import Payment, User
-from .common import Translator, format_human_units, make_translator, sale_mode_base
+from .common import (
+ Translator,
+ format_human_units,
+ make_translator,
+ sale_mode_base,
+ sale_mode_tariff_key,
+)
_TRAFFIC_MODES = {"traffic", "traffic_package", "topup", "premium_topup"}
_HWID_DEVICE_MODES = {"hwid_device", "hwid_devices", "hwid_devices_renewal"}
@@ -304,6 +310,7 @@ async def finalize_successful_payment(
activation_months or 1,
current_payment_db_id=req.payment.payment_id,
skip_if_active_before_payment=False,
+ tariff_key=sale_mode_tariff_key(req.sale_mode),
)
await req.session.commit()
except Exception:
diff --git a/backend/bot/payment_providers/yookassa.py b/backend/bot/payment_providers/yookassa.py
index 1fff5cc..26c1194 100644
--- a/backend/bot/payment_providers/yookassa.py
+++ b/backend/bot/payment_providers/yookassa.py
@@ -75,6 +75,9 @@ from .shared import (
from .shared import (
sale_mode_base as _sale_mode_base,
)
+from .shared import (
+ sale_mode_tariff_key as _sale_mode_tariff_key,
+)
class YooKassaConfig(ProviderEnvConfig):
@@ -749,6 +752,7 @@ async def process_successful_payment(
months_for_activation or int(subscription_months) or 1,
current_payment_db_id=payment_db_id,
skip_if_active_before_payment=False,
+ tariff_key=_sale_mode_tariff_key(sale_mode),
)
applied_referee_bonus_days_from_referral: Optional[int] = None
if referral_bonus_info and referral_bonus_info.get("referee_new_end_date"):
diff --git a/backend/bot/services/referral_service.py b/backend/bot/services/referral_service.py
index 6138fd3..c533d14 100644
--- a/backend/bot/services/referral_service.py
+++ b/backend/bot/services/referral_service.py
@@ -33,6 +33,7 @@ class ReferralService:
purchased_subscription_months: int,
current_payment_db_id: Optional[int] = None,
skip_if_active_before_payment: bool = True,
+ tariff_key: Optional[str] = None,
) -> Dict[str, Any]:
referee_final_end_date: Optional[datetime] = None
@@ -94,11 +95,9 @@ class ReferralService:
else self.i18n.gettext(default_lang_for_placeholder, "friend_placeholder")
)
- inviter_bonus_days = self.settings.referral_bonus_inviter.get(
- purchased_subscription_months
- )
- referee_bonus_days = self.settings.referral_bonus_referee.get(
- purchased_subscription_months
+ inviter_bonus_days, referee_bonus_days = self._referral_bonus_days_for_payment(
+ purchased_subscription_months,
+ tariff_key=tariff_key,
)
if inviter_bonus_days and inviter_bonus_days > 0:
@@ -263,6 +262,35 @@ class ReferralService:
raise
+ def _referral_bonus_days_for_payment(
+ self,
+ purchased_subscription_months: int,
+ *,
+ tariff_key: Optional[str] = None,
+ ) -> tuple[Optional[int], Optional[int]]:
+ months = int(purchased_subscription_months)
+ tariffs_config = getattr(self.settings, "tariffs_config", None)
+ if tariff_key and tariffs_config:
+ try:
+ tariff = tariffs_config.require(str(tariff_key))
+ except Exception:
+ logging.warning(
+ "Referral bonuses skipped: tariff %s was not found.",
+ tariff_key,
+ )
+ return None, None
+ if tariff.billing_model != "period":
+ return None, None
+ return (
+ tariff.referral_inviter_bonus_days(months),
+ tariff.referral_referee_bonus_days(months),
+ )
+
+ return (
+ self.settings.referral_bonus_inviter.get(months),
+ self.settings.referral_bonus_referee.get(months),
+ )
+
async def generate_referral_link(
self, session: AsyncSession, bot_username: str, inviter_user_id: int
) -> Optional[str]:
diff --git a/backend/bot/services/subscription_service_impl/lifecycle.py b/backend/bot/services/subscription_service_impl/lifecycle.py
index 3968a2c..603e2b4 100644
--- a/backend/bot/services/subscription_service_impl/lifecycle.py
+++ b/backend/bot/services/subscription_service_impl/lifecycle.py
@@ -704,6 +704,9 @@ class SubscriptionLifecycleMixin:
active_sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, panel_uuid
)
+ preserve_tariff_limits = bool(
+ active_sub and active_sub.tariff_key and self._tariffs_config()
+ )
if not active_sub or not active_sub.end_date:
logging.info(
f"No active subscription found for user {user_id}. Creating new one for {bonus_days} days." # noqa: E501
@@ -748,6 +751,7 @@ class SubscriptionLifecycleMixin:
if (
apply_main_traffic_limit
+ and not preserve_tariff_limits
and updated_sub_model
and updated_sub_model.traffic_limit_bytes != self.settings.user_traffic_limit_bytes
):
@@ -762,7 +766,9 @@ class SubscriptionLifecycleMixin:
panel_update_payload = self._build_panel_update_payload(
expire_at=new_end_date_obj,
traffic_limit_bytes=(
- self.settings.user_traffic_limit_bytes if apply_main_traffic_limit else None
+ self.settings.user_traffic_limit_bytes
+ if apply_main_traffic_limit and not preserve_tariff_limits
+ else None
),
include_uuid=False,
include_default_squads=False,
diff --git a/backend/config/settings.py b/backend/config/settings.py
index f7959ab..b256771 100644
--- a/backend/config/settings.py
+++ b/backend/config/settings.py
@@ -214,10 +214,10 @@ class Settings(BaseSettings):
MONTH_6_ENABLED: bool = Field(default=True, alias="6_MONTHS_ENABLED")
MONTH_12_ENABLED: bool = Field(default=True, alias="12_MONTHS_ENABLED")
- RUB_PRICE_1_MONTH: Optional[int] = Field(default=None)
- RUB_PRICE_3_MONTHS: Optional[int] = Field(default=None)
- RUB_PRICE_6_MONTHS: Optional[int] = Field(default=None)
- RUB_PRICE_12_MONTHS: Optional[int] = Field(default=None)
+ RUB_PRICE_1_MONTH: Optional[int] = Field(default=200)
+ RUB_PRICE_3_MONTHS: Optional[int] = Field(default=600)
+ RUB_PRICE_6_MONTHS: Optional[int] = Field(default=1200)
+ RUB_PRICE_12_MONTHS: Optional[int] = Field(default=2400)
STARS_PRICE_1_MONTH: Optional[int] = Field(default=None)
STARS_PRICE_3_MONTHS: Optional[int] = Field(default=None)
diff --git a/backend/config/tariffs_config.py b/backend/config/tariffs_config.py
index 19b81e4..c382c6b 100644
--- a/backend/config/tariffs_config.py
+++ b/backend/config/tariffs_config.py
@@ -95,6 +95,8 @@ class Tariff(BaseModel):
monthly_gb: Optional[float] = None
prices_rub: Dict[str, float] = Field(default_factory=dict)
prices_stars: Dict[str, float] = Field(default_factory=dict)
+ referral_bonus_days_inviter: Dict[str, int] = Field(default_factory=dict)
+ referral_bonus_days_referee: Dict[str, int] = Field(default_factory=dict)
enabled_periods: List[int] = Field(default_factory=list)
topup_packages: Optional[PackageSet] = None
@@ -129,6 +131,14 @@ class Tariff(BaseModel):
if self.billing_model == "period":
if self.monthly_gb is None or self.monthly_gb < 0:
raise ValueError(f"period tariff {self.key}: monthly_gb must be >= 0")
+ self.referral_bonus_days_inviter = self._normalize_referral_bonus_map(
+ self.referral_bonus_days_inviter,
+ "referral_bonus_days_inviter",
+ )
+ self.referral_bonus_days_referee = self._normalize_referral_bonus_map(
+ self.referral_bonus_days_referee,
+ "referral_bonus_days_referee",
+ )
if not self.enabled_periods:
raise ValueError(f"period tariff {self.key}: enabled_periods is required")
for months in self.enabled_periods:
@@ -139,7 +149,7 @@ class Tariff(BaseModel):
if rub_price <= 0 and stars_price <= 0:
raise ValueError(
f"period tariff {self.key}: period {months} needs a non-zero rub or stars price" # noqa: E501
- )
+ )
return self
if not self.traffic_packages or not self.traffic_packages.has_any():
@@ -152,6 +162,23 @@ class Tariff(BaseModel):
)
return self
+ def _normalize_referral_bonus_map(
+ self, values: Dict[str, int], field_name: str
+ ) -> Dict[str, int]:
+ normalized: Dict[str, int] = {}
+ for period, days in (values or {}).items():
+ try:
+ months = int(float(str(period).strip()))
+ bonus_days = int(float(days))
+ except (TypeError, ValueError):
+ raise ValueError(f"tariff {self.key}: {field_name} contains invalid entry")
+ if months <= 0:
+ raise ValueError(f"tariff {self.key}: {field_name} periods must be positive")
+ if bonus_days < 0:
+ raise ValueError(f"tariff {self.key}: {field_name} days must be >= 0")
+ normalized[str(months)] = bonus_days
+ return normalized
+
def name(self, lang: str, fallback: str = "ru") -> str:
return self.names.get(lang) or self.names.get(fallback) or self.key
@@ -173,6 +200,14 @@ class Tariff(BaseModel):
value = source.get(str(months))
return float(value) if value is not None else None
+ def referral_inviter_bonus_days(self, months: int) -> Optional[int]:
+ value = self.referral_bonus_days_inviter.get(str(int(months)))
+ return int(value) if value is not None else None
+
+ def referral_referee_bonus_days(self, months: int) -> Optional[int]:
+ value = self.referral_bonus_days_referee.get(str(int(months)))
+ return int(value) if value is not None else None
+
def min_period_price_rub(self) -> Optional[float]:
prices = [
float(self.prices_rub[str(months)])
diff --git a/data/tariffs.example.json b/data/tariffs.example.json
index 7c8cce1..03262a3 100644
--- a/data/tariffs.example.json
+++ b/data/tariffs.example.json
@@ -40,8 +40,10 @@
{ "count": 1, "price": 2500 }
]
},
- "prices_rub": { "1": 150, "3": 400, "6": 750, "12": 1400 },
+ "prices_rub": { "1": 200, "3": 600, "6": 1200, "12": 2400 },
"prices_stars": { "1": 0, "3": 0, "6": 0, "12": 0 },
+ "referral_bonus_days_inviter": { "1": 3, "3": 7, "6": 15, "12": 30 },
+ "referral_bonus_days_referee": { "1": 1, "3": 3, "6": 7, "12": 15 },
"enabled_periods": [1, 3, 6, 12],
"enabled": true
},
diff --git a/frontend/src/admin/sections/TariffEditorModal.svelte b/frontend/src/admin/sections/TariffEditorModal.svelte
index cd983a8..ef6c10f 100644
--- a/frontend/src/admin/sections/TariffEditorModal.svelte
+++ b/frontend/src/admin/sections/TariffEditorModal.svelte
@@ -501,7 +501,13 @@
{t("wa_referral_bonus_paid_intro")}
{/if} - {#each referralBonusDetails as bonus, index (bonus.months || index)} + {#each referralBonusDetails as bonus, index (bonus.id || `${bonus.tariff_key || "legacy"}:${bonus.months || index}`)}