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 @@ - tariffsStore.addDraftRow("periodRows", { months: 1, rub: "", stars: "" })} + tariffsStore.addDraftRow("periodRows", { + months: 1, + rub: "", + stars: "", + referral_inviter: "", + referral_referee: "", + })} > {at("tariff_btn_period", {}, "Период")} @@ -517,14 +523,16 @@

{:else}
-
+
{at("tariff_col_period_months", {}, "Срок, мес.")} {at("tariff_col_price_rub", {}, "Цена, ₽")} {at("tariff_col_price_stars_full", {}, "Цена, ⭐ Stars")} + {at("tariff_col_ref_inviter", {}, "Бонус приглашающему")} + {at("tariff_col_ref_referee", {}, "Бонус приглашённому")}
{#each tariffDraft.periodRows as row, index} -
+
+ + row.slice(1)), @@ -794,8 +822,10 @@ {at("tariffs_legacy_enabled", {}, "Enabled")} {at("payment_rub", {}, "RUB")} {at("payment_stars", {}, "Stars")} + {at("tariffs_legacy_ref_inviter", {}, "Inviter")} + {at("tariffs_legacy_ref_referee", {}, "Friend")}
- {#each LEGACY_PERIODS as [months, enabledKey, rubKey, starsKey]} + {#each LEGACY_PERIODS as [months, enabledKey, rubKey, starsKey, inviterKey, refereeKey]}
{months} {at("months_short", {}, "mo")}
@@ -823,6 +853,22 @@ value={valueForKey(starsKey, settingsDirty, settingsFieldMap)} oninput={(event) => setSetting(starsKey, event.currentTarget.value)} /> + setSetting(inviterKey, event.currentTarget.value)} + /> + setSetting(refereeKey, event.currentTarget.value)} + />
{/each}
diff --git a/frontend/src/lib/admin/tariffDraft.js b/frontend/src/lib/admin/tariffDraft.js index 416cb58..1430753 100644 --- a/frontend/src/lib/admin/tariffDraft.js +++ b/frontend/src/lib/admin/tariffDraft.js @@ -18,10 +18,10 @@ export function emptyTariffDraft() { hwid_device_limit: "", conversion_rate_rub_per_gb: "", periodRows: [ - { months: 1, rub: 150, stars: "" }, - { months: 3, rub: 400, stars: "" }, - { months: 6, rub: 750, stars: "" }, - { months: 12, rub: 1400, stars: "" }, + { months: 1, rub: 200, stars: "", referral_inviter: 3, referral_referee: 1 }, + { months: 3, rub: 600, stars: "", referral_inviter: 7, referral_referee: 3 }, + { months: 6, rub: 1200, stars: "", referral_inviter: 15, referral_referee: 7 }, + { months: 12, rub: 2400, stars: "", referral_inviter: 30, referral_referee: 15 }, ], topupRubRows: [], topupStarsRows: [], @@ -67,6 +67,8 @@ export function draftFromTariff(tariff) { months: month, rub: tariff.prices_rub?.[String(month)] ?? "", stars: tariff.prices_stars?.[String(month)] ?? "", + referral_inviter: tariff.referral_bonus_days_inviter?.[String(month)] ?? "", + referral_referee: tariff.referral_bonus_days_referee?.[String(month)] ?? "", })); return { @@ -190,6 +192,8 @@ export function tariffFromDraft(draft) { months: parseIntNumber(row.months), rub: parseNumber(row.rub, 0), stars: parseNumber(row.stars, 0), + referral_inviter: parseIntNumber(row.referral_inviter), + referral_referee: parseIntNumber(row.referral_referee), })) .filter((row) => row.months > 0) .filter((row) => { @@ -204,6 +208,16 @@ export function tariffFromDraft(draft) { tariff.prices_stars = Object.fromEntries( rows.map((row) => [String(row.months), row.stars || 0]) ); + tariff.referral_bonus_days_inviter = Object.fromEntries( + rows + .filter((row) => row.referral_inviter !== null) + .map((row) => [String(row.months), row.referral_inviter]) + ); + tariff.referral_bonus_days_referee = Object.fromEntries( + rows + .filter((row) => row.referral_referee !== null) + .map((row) => [String(row.months), row.referral_referee]) + ); const topupPackages = packageSetFromRows(draft.topupRubRows, draft.topupStarsRows, "gb"); if (topupPackages) tariff.topup_packages = topupPackages; } else { diff --git a/frontend/src/lib/webapp/mockApi.js b/frontend/src/lib/webapp/mockApi.js index a96dc9e..eaedaeb 100644 --- a/frontend/src/lib/webapp/mockApi.js +++ b/frontend/src/lib/webapp/mockApi.js @@ -1689,17 +1689,25 @@ export async function mockApi(path, options = {}, context = {}) { }, ...[ ["MONTH_1_ENABLED", "bool", true], - ["RUB_PRICE_1_MONTH", "float", 150], + ["RUB_PRICE_1_MONTH", "float", 200], ["STARS_PRICE_1_MONTH", "int", 0], + ["REFERRAL_BONUS_DAYS_INVITER_1_MONTH", "int", 3], + ["REFERRAL_BONUS_DAYS_REFEREE_1_MONTH", "int", 1], ["MONTH_3_ENABLED", "bool", true], - ["RUB_PRICE_3_MONTHS", "float", 400], + ["RUB_PRICE_3_MONTHS", "float", 600], ["STARS_PRICE_3_MONTHS", "int", 0], + ["REFERRAL_BONUS_DAYS_INVITER_3_MONTHS", "int", 7], + ["REFERRAL_BONUS_DAYS_REFEREE_3_MONTHS", "int", 3], ["MONTH_6_ENABLED", "bool", false], - ["RUB_PRICE_6_MONTHS", "float", 750], + ["RUB_PRICE_6_MONTHS", "float", 1200], ["STARS_PRICE_6_MONTHS", "int", 0], + ["REFERRAL_BONUS_DAYS_INVITER_6_MONTHS", "int", 15], + ["REFERRAL_BONUS_DAYS_REFEREE_6_MONTHS", "int", 7], ["MONTH_12_ENABLED", "bool", false], - ["RUB_PRICE_12_MONTHS", "float", 1200], + ["RUB_PRICE_12_MONTHS", "float", 2400], ["STARS_PRICE_12_MONTHS", "int", 0], + ["REFERRAL_BONUS_DAYS_INVITER_12_MONTHS", "int", 30], + ["REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS", "int", 15], ["TRAFFIC_PACKAGES", "string", "10:99,50:399"], ["STARS_TRAFFIC_PACKAGES", "string", ""], ].map(([key, type, value]) => ({ diff --git a/frontend/src/styles/admin.css b/frontend/src/styles/admin.css index 8653f68..a8c383d 100644 --- a/frontend/src/styles/admin.css +++ b/frontend/src/styles/admin.css @@ -3274,7 +3274,9 @@ .admin-legacy-tariff-row { display: grid; - grid-template-columns: minmax(72px, 0.7fr) minmax(92px, 0.8fr) minmax(0, 1fr) minmax(0, 1fr); + grid-template-columns: + minmax(58px, 0.7fr) minmax(74px, 0.8fr) minmax(0, 1fr) minmax(0, 1fr) + minmax(0, 1fr) minmax(0, 1fr); gap: 10px; align-items: center; min-width: 0; @@ -3393,6 +3395,12 @@ grid-template-columns: minmax(80px, 0.8fr) minmax(100px, 1fr) minmax(100px, 1fr) 32px; } +.admin-row-editor-line.admin-row-editor-6 { + grid-template-columns: + minmax(72px, 0.8fr) minmax(90px, 1fr) minmax(90px, 1fr) minmax(120px, 1fr) + minmax(120px, 1fr) 32px; +} + .admin-package-columns { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -3568,7 +3576,8 @@ .admin-form-row.admin-form-row-3, .admin-package-columns, .admin-row-editor-line, - .admin-row-editor-line.admin-row-editor-4 { + .admin-row-editor-line.admin-row-editor-4, + .admin-row-editor-line.admin-row-editor-6 { grid-template-columns: 1fr; } diff --git a/frontend/src/webapp/screens/InviteScreen.svelte b/frontend/src/webapp/screens/InviteScreen.svelte index 0be3e9e..1d1ed13 100644 --- a/frontend/src/webapp/screens/InviteScreen.svelte +++ b/frontend/src/webapp/screens/InviteScreen.svelte @@ -58,7 +58,7 @@ {#if referralBonusDetails.length}

{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}`)}
{bonus.title || `${bonus.months || "?"}`} SimpleNamespace: @@ -338,6 +339,87 @@ class RefereeBonusTests(unittest.IsolatedAsyncioTestCase): self.assertFalse(result["inviter_bonus_applied_flag"]) subscription_service.extend_active_subscription_days.assert_not_called() + async def test_tariff_bonus_uses_referee_purchase_tariff(self): + data = { + "default_tariff": "standard", + "tariffs": [ + { + "key": "standard", + "names": {"en": "Standard"}, + "descriptions": {}, + "squad_uuids": ["standard-squad"], + "billing_model": "period", + "monthly_gb": 100, + "prices_rub": {"2": 400}, + "prices_stars": {}, + "enabled_periods": [2], + "referral_bonus_days_inviter": {"2": 5}, + "referral_bonus_days_referee": {"2": 1}, + "enabled": True, + }, + { + "key": "premium", + "names": {"en": "Premium"}, + "descriptions": {}, + "squad_uuids": ["premium-squad"], + "billing_model": "period", + "monthly_gb": 500, + "prices_rub": {"2": 700}, + "prices_stars": {}, + "enabled_periods": [2], + "referral_bonus_days_inviter": {"2": 20}, + "referral_bonus_days_referee": {"2": 7}, + "enabled": True, + }, + ], + } + settings = _make_settings( + REFERRAL_ONE_BONUS_PER_REFEREE=False, + tariffs_config=TariffsConfig.model_validate(data), + ) + subscription_service = AsyncMock() + subscription_service.has_active_subscription = AsyncMock(return_value=False) + subscription_service._get_or_create_panel_user_link_details = AsyncMock( + return_value=("inviter-panel", "inviter-sub", "short", False) + ) + inviter_new_end = datetime(2026, 4, 1, tzinfo=timezone.utc) + referee_new_end = datetime(2026, 5, 1, tzinfo=timezone.utc) + subscription_service.extend_active_subscription_days = AsyncMock( + side_effect=[inviter_new_end, referee_new_end] + ) + service, _bot = _make_service(settings=settings, subscription_service=subscription_service) + + with patch( + "bot.services.referral_service.user_dal.get_user_by_id", + AsyncMock( + side_effect=lambda session, uid: ( + _make_user(uid, referred_by_id=1) if uid == 42 else _make_user(uid) + ) + ), + ): + result = await service.apply_referral_bonuses_for_payment( + session=AsyncMock(), + referee_user_id=42, + purchased_subscription_months=2, + skip_if_active_before_payment=False, + tariff_key="premium", + ) + + self.assertEqual(result["referee_bonus_applied_days"], 7) + self.assertTrue(result["inviter_bonus_applied_flag"]) + inviter_call = [ + call + for call in subscription_service.extend_active_subscription_days.await_args_list + if call.kwargs.get("user_id") == 1 + ][0] + referee_call = [ + call + for call in subscription_service.extend_active_subscription_days.await_args_list + if call.kwargs.get("user_id") == 42 + ][0] + self.assertEqual(inviter_call.kwargs["bonus_days"], 20) + self.assertEqual(referee_call.kwargs["bonus_days"], 7) + class GenerateReferralLinkTests(unittest.IsolatedAsyncioTestCase): async def test_includes_bot_username_and_referral_code(self): diff --git a/tests/test_settings.py b/tests/test_settings.py index 95414d4..19fa416 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -42,6 +42,20 @@ class SettingsTests(unittest.TestCase): self.assertEqual(settings.WEBAPP_TITLE, "/minishop") + def test_legacy_subscription_prices_have_defaults(self): + settings = Settings( + _env_file=None, + BOT_TOKEN="token", + POSTGRES_USER="app_user", + POSTGRES_PASSWORD="app_password", + TARIFFS_CONFIG_PATH="missing-tariffs.json", + ) + + self.assertEqual( + settings.subscription_options, + {1: 200.0, 3: 600.0, 6: 1200.0, 12: 2400.0}, + ) + def test_subscription_guides_defaults_are_enabled(self): settings = Settings( _env_file=None, diff --git a/tests/test_subscription_service_behavior.py b/tests/test_subscription_service_behavior.py index 592b901..0a36276 100644 --- a/tests/test_subscription_service_behavior.py +++ b/tests/test_subscription_service_behavior.py @@ -393,6 +393,65 @@ class SubscriptionServiceActivationDispatchTests(unittest.IsolatedAsyncioTestCas self.assertEqual(kwargs["payment_db_id"], 12) +class SubscriptionServiceBonusExtensionTests(unittest.IsolatedAsyncioTestCase): + async def test_referral_extension_preserves_existing_tariff_limit(self): + with tempfile.TemporaryDirectory() as tmpdir: + settings = _make_settings( + _tariffs_config_payload(), + tmpdir, + USER_TRAFFIC_LIMIT_GB=999, + ) + service = _make_service(settings) + service._get_or_create_panel_user_link_details = AsyncMock( + return_value=("panel-user", "short-uuid", "short", False) + ) + service.panel_service.update_user_details_on_panel = AsyncMock( + return_value={"ok": True} + ) + active_sub = SimpleNamespace( + subscription_id=10, + end_date=datetime.now(timezone.utc) + timedelta(days=5), + traffic_limit_bytes=100 * GIB, + tariff_key="standard", + ) + updated_sub = SimpleNamespace( + subscription_id=10, + end_date=active_sub.end_date + timedelta(days=3), + traffic_limit_bytes=100 * GIB, + tariff_key="standard", + ) + + with ( + patch( + "bot.services.subscription_service_impl.lifecycle.user_dal.get_user_by_id", + AsyncMock(return_value=SimpleNamespace(user_id=42)), + ), + patch( + "bot.services.subscription_service_impl.lifecycle.subscription_dal.get_active_subscription_by_user_id", + AsyncMock(return_value=active_sub), + ), + patch( + "bot.services.subscription_service_impl.lifecycle.subscription_dal.update_subscription_end_date", + AsyncMock(return_value=updated_sub), + ), + patch( + "bot.services.subscription_service_impl.lifecycle.subscription_dal.update_subscription", + AsyncMock(), + ) as update_subscription, + ): + await service.extend_active_subscription_days( + session=AsyncMock(), + user_id=42, + bonus_days=3, + reason="referral bonus from Alice", + ) + + update_subscription.assert_not_awaited() + payload = service.panel_service.update_user_details_on_panel.await_args.args[1] + self.assertNotIn("trafficLimitBytes", payload) + self.assertNotIn("trafficLimitStrategy", payload) + + class SubscriptionServiceActiveDetailsTests(unittest.IsolatedAsyncioTestCase): def _local_active_sub(self) -> SimpleNamespace: return SimpleNamespace( diff --git a/tests/test_tariffs_config.py b/tests/test_tariffs_config.py index b8c5067..a717f75 100644 --- a/tests/test_tariffs_config.py +++ b/tests/test_tariffs_config.py @@ -70,6 +70,28 @@ class TariffsConfigTests(unittest.TestCase): self.assertIsNotNone(packages) self.assertEqual(packages.rub[0].gb, 25) + def test_period_tariff_referral_bonuses_load(self): + data = _valid_config() + data["tariffs"][0]["referral_bonus_days_inviter"] = {"2": 5, "4": 10} + data["tariffs"][0]["referral_bonus_days_referee"] = {"2": 1, "4": 2} + data["tariffs"][0]["prices_rub"] = {"2": 400, "4": 800} + data["tariffs"][0]["prices_stars"] = {} + data["tariffs"][0]["enabled_periods"] = [2, 4] + + config = TariffsConfig.model_validate(data) + tariff = config.require("standard") + + self.assertEqual(tariff.referral_inviter_bonus_days(2), 5) + self.assertEqual(tariff.referral_referee_bonus_days(4), 2) + self.assertIsNone(tariff.referral_inviter_bonus_days(8)) + + def test_negative_tariff_referral_bonus_rejected(self): + data = _valid_config() + data["tariffs"][0]["referral_bonus_days_inviter"] = {"1": -1} + + with self.assertRaises(ValueError): + TariffsConfig.model_validate(data) + def test_missing_config_returns_none(self): import tempfile from pathlib import Path diff --git a/tests/test_webapp_assets.py b/tests/test_webapp_assets.py index 2bbcf0b..ab44f22 100644 --- a/tests/test_webapp_assets.py +++ b/tests/test_webapp_assets.py @@ -83,6 +83,98 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(plans[1]["traffic_gb"], 50.0) self.assertEqual(plans[1]["stars_price"], 2500) + def test_referral_bonus_details_use_custom_tariff_periods(self): + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "tariffs.json" + path.write_text( + json.dumps( + { + "default_tariff": "standard", + "tariffs": [ + { + "key": "standard", + "names": {"en": "Standard"}, + "descriptions": {"en": "Custom periods"}, + "squad_uuids": ["uuid"], + "billing_model": "period", + "monthly_gb": 100, + "prices_rub": { + "2": 400, + "4": 800, + "8": 1600, + "16": 3200, + }, + "prices_stars": {}, + "referral_bonus_days_inviter": { + "2": 5, + "4": 10, + "16": 40, + }, + "referral_bonus_days_referee": { + "2": 1, + "4": 2, + "8": 4, + }, + "enabled_periods": [2, 4, 8, 16], + "enabled": True, + } + ], + } + ), + encoding="utf-8", + ) + settings = Settings( + _env_file=None, + BOT_TOKEN="token", + POSTGRES_USER="app_user", + POSTGRES_PASSWORD="app_password", + TARIFFS_CONFIG_PATH=str(path), + ) + + details = subscription_webapp._serialize_referral_bonus_details(settings, "en") + + self.assertEqual( + details, + [ + { + "id": "standard:2", + "tariff_key": "standard", + "tariff_name": "Standard", + "months": 2, + "title": "Standard - 2 months", + "inviter_days": 5, + "friend_days": 1, + }, + { + "id": "standard:4", + "tariff_key": "standard", + "tariff_name": "Standard", + "months": 4, + "title": "Standard - 4 months", + "inviter_days": 10, + "friend_days": 2, + }, + { + "id": "standard:8", + "tariff_key": "standard", + "tariff_name": "Standard", + "months": 8, + "title": "Standard - 8 months", + "inviter_days": 0, + "friend_days": 4, + }, + { + "id": "standard:16", + "tariff_key": "standard", + "tariff_name": "Standard", + "months": 16, + "title": "Standard - 16 months", + "inviter_days": 40, + "friend_days": 0, + }, + ], + ) + def test_subscription_template_does_not_block_on_telegram_sdk(self): html = subscription_webapp.TEMPLATE_PATH.read_text(encoding="utf-8") @@ -653,6 +745,9 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): YOOKASSA_ENABLED=False, CRYPTOPAY_ENABLED=False, TARIFFS_CONFIG_PATH="missing-tariffs.json", + MONTH_3_ENABLED=False, + MONTH_6_ENABLED=False, + MONTH_12_ENABLED=False, RUB_PRICE_1_MONTH=None, STARS_PRICE_1_MONTH=250, )