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)])
+3 -1
View File
@@ -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
},
@@ -501,7 +501,13 @@
<AdminButton
size="sm"
onclick={() =>
tariffsStore.addDraftRow("periodRows", { months: 1, rub: "", stars: "" })}
tariffsStore.addDraftRow("periodRows", {
months: 1,
rub: "",
stars: "",
referral_inviter: "",
referral_referee: "",
})}
>
<Plus size={13} />
{at("tariff_btn_period", {}, "Период")}
@@ -517,14 +523,16 @@
</p>
{:else}
<div class="admin-row-editor">
<div class="admin-row-editor-line admin-row-editor-4 admin-row-editor-header">
<div class="admin-row-editor-line admin-row-editor-6 admin-row-editor-header">
<span>{at("tariff_col_period_months", {}, "Срок, мес.")}</span>
<span>{at("tariff_col_price_rub", {}, "Цена, ₽")}</span>
<span>{at("tariff_col_price_stars_full", {}, "Цена, ⭐ Stars")}</span>
<span>{at("tariff_col_ref_inviter", {}, "Бонус приглашающему")}</span>
<span>{at("tariff_col_ref_referee", {}, "Бонус приглашённому")}</span>
<span></span>
</div>
{#each tariffDraft.periodRows as row, index}
<div class="admin-row-editor-line admin-row-editor-4">
<div class="admin-row-editor-line admin-row-editor-6">
<input
class="input"
type="number"
@@ -551,6 +559,24 @@
bind:value={row.stars}
aria-label={at("tariff_label_price_stars", {}, "Цена в Telegram Stars")}
/>
<input
class="input"
type="number"
min="0"
step="1"
placeholder="3"
bind:value={row.referral_inviter}
aria-label={at("tariff_label_ref_inviter", {}, "Бонус приглашающему")}
/>
<input
class="input"
type="number"
min="0"
step="1"
placeholder="1"
bind:value={row.referral_referee}
aria-label={at("tariff_label_ref_referee", {}, "Бонус приглашённому")}
/>
<AdminButton
size="sm"
variant="danger"
@@ -36,10 +36,38 @@
const TRIAL_RESET_KEYS = ["TRIAL_TRAFFIC_STRATEGY"];
const TRIAL_SQUAD_KEYS = ["TRIAL_SQUAD_UUIDS"];
const LEGACY_PERIODS = [
["1", "MONTH_1_ENABLED", "RUB_PRICE_1_MONTH", "STARS_PRICE_1_MONTH"],
["3", "MONTH_3_ENABLED", "RUB_PRICE_3_MONTHS", "STARS_PRICE_3_MONTHS"],
["6", "MONTH_6_ENABLED", "RUB_PRICE_6_MONTHS", "STARS_PRICE_6_MONTHS"],
["12", "MONTH_12_ENABLED", "RUB_PRICE_12_MONTHS", "STARS_PRICE_12_MONTHS"],
[
"1",
"MONTH_1_ENABLED",
"RUB_PRICE_1_MONTH",
"STARS_PRICE_1_MONTH",
"REFERRAL_BONUS_DAYS_INVITER_1_MONTH",
"REFERRAL_BONUS_DAYS_REFEREE_1_MONTH",
],
[
"3",
"MONTH_3_ENABLED",
"RUB_PRICE_3_MONTHS",
"STARS_PRICE_3_MONTHS",
"REFERRAL_BONUS_DAYS_INVITER_3_MONTHS",
"REFERRAL_BONUS_DAYS_REFEREE_3_MONTHS",
],
[
"6",
"MONTH_6_ENABLED",
"RUB_PRICE_6_MONTHS",
"STARS_PRICE_6_MONTHS",
"REFERRAL_BONUS_DAYS_INVITER_6_MONTHS",
"REFERRAL_BONUS_DAYS_REFEREE_6_MONTHS",
],
[
"12",
"MONTH_12_ENABLED",
"RUB_PRICE_12_MONTHS",
"STARS_PRICE_12_MONTHS",
"REFERRAL_BONUS_DAYS_INVITER_12_MONTHS",
"REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS",
],
];
const LEGACY_TARIFF_SETTING_KEYS = [
...LEGACY_PERIODS.flatMap((row) => row.slice(1)),
@@ -794,8 +822,10 @@
<span>{at("tariffs_legacy_enabled", {}, "Enabled")}</span>
<span>{at("payment_rub", {}, "RUB")}</span>
<span>{at("payment_stars", {}, "Stars")}</span>
<span>{at("tariffs_legacy_ref_inviter", {}, "Inviter")}</span>
<span>{at("tariffs_legacy_ref_referee", {}, "Friend")}</span>
</div>
{#each LEGACY_PERIODS as [months, enabledKey, rubKey, starsKey]}
{#each LEGACY_PERIODS as [months, enabledKey, rubKey, starsKey, inviterKey, refereeKey]}
<div class="admin-legacy-tariff-row">
<strong>{months} {at("months_short", {}, "mo")}</strong>
<div class="admin-setting-switch">
@@ -823,6 +853,22 @@
value={valueForKey(starsKey, settingsDirty, settingsFieldMap)}
oninput={(event) => setSetting(starsKey, event.currentTarget.value)}
/>
<input
class="input"
type="number"
min="0"
step="1"
value={valueForKey(inviterKey, settingsDirty, settingsFieldMap)}
oninput={(event) => setSetting(inviterKey, event.currentTarget.value)}
/>
<input
class="input"
type="number"
min="0"
step="1"
value={valueForKey(refereeKey, settingsDirty, settingsFieldMap)}
oninput={(event) => setSetting(refereeKey, event.currentTarget.value)}
/>
</div>
{/each}
</div>
+18 -4
View File
@@ -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 {
+12 -4
View File
@@ -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]) => ({
+11 -2
View File
@@ -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;
}
@@ -58,7 +58,7 @@
{#if referralBonusDetails.length}
<p class="referral-bonus-intro">{t("wa_referral_bonus_paid_intro")}</p>
{/if}
{#each referralBonusDetails as bonus, index (bonus.months || index)}
{#each referralBonusDetails as bonus, index (bonus.id || `${bonus.tariff_key || "legacy"}:${bonus.months || index}`)}
<div class="referral-bonus-row">
<strong>{bonus.title || `${bonus.months || "?"}`}</strong>
<small
+6
View File
@@ -1261,10 +1261,14 @@
"admin_tariff_col_price_stars": "Price, ⭐",
"admin_tariff_col_price_stars_full": "Price, ⭐ Stars",
"admin_tariff_col_period_months": "Period, mo.",
"admin_tariff_col_ref_inviter": "Inviter bonus",
"admin_tariff_col_ref_referee": "Friend bonus",
"admin_tariff_col_hwid_count": "+ devices",
"admin_tariff_label_hwid_count_full": "How many devices does this package add",
"admin_tariff_label_price_rub": "Price in Rubles",
"admin_tariff_label_price_stars": "Price in Telegram Stars",
"admin_tariff_label_ref_inviter": "Inviter bonus, days",
"admin_tariff_label_ref_referee": "Friend bonus, days",
"admin_aria_label_main_traffic": "Main traffic usage",
"admin_aria_label_premium_traffic": "Premium traffic usage",
"admin_btn_delete": "Delete",
@@ -1347,6 +1351,8 @@
"admin_tariffs_legacy_subtitle": "Old remnawave-tg-shop periods and traffic packages used only when the JSON tariff catalog is not configured.",
"admin_tariffs_legacy_period": "Period",
"admin_tariffs_legacy_enabled": "Enabled",
"admin_tariffs_legacy_ref_inviter": "Inviter bonus",
"admin_tariffs_legacy_ref_referee": "Friend bonus",
"admin_tariffs_legacy_stars_traffic_packages": "Traffic packages, Stars",
"admin_tariffs_legacy_traffic_hint": "Format: 10:199,50:799",
"admin_tariff_tab_general": "General",
+6
View File
@@ -1261,10 +1261,14 @@
"admin_tariff_col_price_stars": "Цена, ⭐",
"admin_tariff_col_price_stars_full": "Цена, ⭐ Stars",
"admin_tariff_col_period_months": "Срок, мес.",
"admin_tariff_col_ref_inviter": "Бонус пригласившему",
"admin_tariff_col_ref_referee": "Бонус приглашённому",
"admin_tariff_col_hwid_count": "+ устройств",
"admin_tariff_label_hwid_count_full": "Сколько устройств добавляет пакет",
"admin_tariff_label_price_rub": "Цена в рублях",
"admin_tariff_label_price_stars": "Цена в Telegram Stars",
"admin_tariff_label_ref_inviter": "Бонус пригласившему, дней",
"admin_tariff_label_ref_referee": "Бонус приглашённому, дней",
"admin_aria_label_main_traffic": "Использование основного трафика",
"admin_aria_label_premium_traffic": "Использование premium-трафика",
"admin_btn_delete": "Удалить",
@@ -1347,6 +1351,8 @@
"admin_tariffs_legacy_subtitle": "Старые периоды и пакеты трафика remnawave-tg-shop, которые используются только без JSON-каталога.",
"admin_tariffs_legacy_period": "Период",
"admin_tariffs_legacy_enabled": "Включён",
"admin_tariffs_legacy_ref_inviter": "Бонус пригласившему",
"admin_tariffs_legacy_ref_referee": "Бонус другу",
"admin_tariffs_legacy_stars_traffic_packages": "Пакеты трафика, Stars",
"admin_tariffs_legacy_traffic_hint": "Формат: 10:199,50:799",
"admin_tariff_tab_general": "Основное",
@@ -66,6 +66,8 @@ ADMIN_TARIFF_SETTINGS_PAGE_KEYS = {
"admin_tariffs_legacy_subtitle",
"admin_tariffs_legacy_period",
"admin_tariffs_legacy_enabled",
"admin_tariffs_legacy_ref_inviter",
"admin_tariffs_legacy_ref_referee",
"admin_tariffs_legacy_traffic_packages",
"admin_tariffs_legacy_stars_traffic_packages",
"admin_tariffs_legacy_traffic_hint",
+82
View File
@@ -23,6 +23,7 @@ from typing import Any, Optional
from unittest.mock import AsyncMock, patch
from bot.services.referral_service import ReferralService
from config.tariffs_config import TariffsConfig
def _make_settings(**overrides: Any) -> 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):
+14
View File
@@ -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,
@@ -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(
+22
View File
@@ -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
+95
View File
@@ -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,
)