fix: configure referral bonuses per tariff period

This commit is contained in:
3252a8
2026-05-29 22:30:31 +03:00
parent cab963dcdc
commit 6803c7801f
23 changed files with 604 additions and 90 deletions
+64 -56
View File
@@ -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",
+37 -2
View File
@@ -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:
@@ -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:
@@ -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"):
+33 -5
View File
@@ -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]:
@@ -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,
+4 -4
View File
@@ -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)
+36 -1
View File
@@ -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)])