Each user gets a short ref_code (backfilled lazily for pre-existing accounts too) and a shareable t.me/<bot>?start=ref_<code> link, new "Пригласить друга" menu item shows it plus how many referrals actually converted and any bonus days waiting to be applied. Reward fires once, on the referred user's first subscription of any kind (free, gift, or paid) — not on signup, so an unconverted click never pays out. Both sides get REFERRAL_BONUS_DAYS (config.py/.env, default 3): the referrer's day count comes from settings.py's live-read pattern, same as prices/HWID, so it's tunable without a restart even before a panel UI exists for it. Bonus extends an active subscription directly if the recipient has one, otherwise accumulates in bonus_days_pending and gets folded into whichever subscription they create next (redeemed automatically inside create_subscription, one choke point regardless of which of bot.py's several call sites created it — free trial, gift code, paid, or admin grant). Guards: no self-referral, referrer must exist, first-touch attribution only (a second ?start=ref_ link never overwrites it), and only takes for genuinely new accounts (no existing subscriptions) — attaching a referrer to an already-active user was never the intent. Tested two ways, matching this repo's usual db.py-can-be-imported- standalone / bot.py-needs-a-workaround split: 16 checks against a real isolated sqlite db for the db.py logic (attribution, both reward paths, double-reward guard, pending-bonus fold-in), then 9 more through an actual `import bot` — aiogram/fastapi now have Python 3.14 wheels so this imported for real rather than needing AST-extraction, modulo one old blocker (xray_manager still imports the Unix-only fcntl for its file lock) worked around with a tiny fake fcntl module in sys.modules, same spirit as the fcntl shim already used elsewhere in this project's history. Real start_deeplink and cb_referral calls, get_me() mocked to avoid a live Telegram API call. Not done: admin-panel UI toggle for REFERRAL_ENABLED/REFERRAL_BONUS_DAYS (currently .env-only, like several other business tunables were before they got a settings-page treatment) and a docs-tab writeup — happy to add both if wanted, scoped this pass to the mechanic itself. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
103 lines
3.3 KiB
Python
103 lines
3.3 KiB
Python
import config
|
|
import legal
|
|
|
|
PRICE_ENV_KEYS = {"7d": "PRICE_7D", "1m": "PRICE_1M", "3m": "PRICE_3M", "6m": "PRICE_6M", "1y": "PRICE_1Y"}
|
|
|
|
|
|
def _bool(raw: str, default: bool) -> bool:
|
|
if raw is None or raw == "":
|
|
return default
|
|
return raw.strip().lower() == "true"
|
|
|
|
|
|
def _positive_int(raw: str, default: int) -> int:
|
|
if raw and raw.strip().lstrip("-").isdigit():
|
|
parsed = int(raw)
|
|
if parsed >= 0:
|
|
return parsed
|
|
return default
|
|
|
|
|
|
def get_plans() -> list:
|
|
raw = legal.read_env_vars(list(PRICE_ENV_KEYS.values()))
|
|
plans = []
|
|
for p in config.PLANS:
|
|
env_key = PRICE_ENV_KEYS[p["code"]]
|
|
plans.append({
|
|
"code": p["code"],
|
|
"label": p["label"],
|
|
"days": p["days"],
|
|
"price": _positive_int(raw.get(env_key), p["price"]),
|
|
})
|
|
return plans
|
|
|
|
|
|
def get_plans_by_code() -> dict:
|
|
return {p["code"]: p for p in get_plans()}
|
|
|
|
|
|
def set_plan_prices(prices: dict):
|
|
for code, price in prices.items():
|
|
if code in PRICE_ENV_KEYS:
|
|
legal.update_env_var(PRICE_ENV_KEYS[code], str(int(price)))
|
|
|
|
|
|
def get_payment_settings() -> dict:
|
|
raw = legal.read_env_vars(["PAYMENTS_ENABLED", "YOOKASSA_ENABLED", "PLATEGA_ENABLED"])
|
|
return {
|
|
"payments_enabled": _bool(raw.get("PAYMENTS_ENABLED"), config.PAYMENTS_ENABLED),
|
|
"yookassa_enabled": _bool(raw.get("YOOKASSA_ENABLED"), config.YOOKASSA_ENABLED),
|
|
"platega_enabled": _bool(raw.get("PLATEGA_ENABLED"), config.PLATEGA_ENABLED),
|
|
}
|
|
|
|
|
|
def yookassa_credentials():
|
|
raw = legal.read_env_vars(["YOOKASSA_SHOP_ID", "YOOKASSA_SECRET_KEY"])
|
|
return (
|
|
raw.get("YOOKASSA_SHOP_ID") or config.YOOKASSA_SHOP_ID,
|
|
raw.get("YOOKASSA_SECRET_KEY") or config.YOOKASSA_SECRET_KEY,
|
|
)
|
|
|
|
|
|
def platega_credentials():
|
|
raw = legal.read_env_vars(["PLATEGA_MERCHANT_ID", "PLATEGA_SECRET"])
|
|
return (
|
|
raw.get("PLATEGA_MERCHANT_ID") or config.PLATEGA_MERCHANT_ID,
|
|
raw.get("PLATEGA_SECRET") or config.PLATEGA_SECRET,
|
|
)
|
|
|
|
|
|
def get_brand_name() -> str:
|
|
raw = legal.read_env_var("BRAND_NAME", "")
|
|
return raw.strip() if raw.strip() else config.BRAND_NAME
|
|
|
|
|
|
def bot_credentials():
|
|
raw = legal.read_env_vars(["BOT_TOKEN", "BOT_USERNAME"])
|
|
return (
|
|
raw.get("BOT_TOKEN") or config.BOT_TOKEN,
|
|
raw.get("BOT_USERNAME") or config.BOT_USERNAME,
|
|
)
|
|
|
|
|
|
def get_hwid_settings() -> dict:
|
|
raw = legal.read_env_vars(["HWID_LIMIT_ENABLED", "HWID_FALLBACK_LIMIT"])
|
|
limit = _positive_int(raw.get("HWID_FALLBACK_LIMIT"), config.HWID_FALLBACK_LIMIT)
|
|
return {
|
|
"enabled": _bool(raw.get("HWID_LIMIT_ENABLED"), config.HWID_LIMIT_ENABLED),
|
|
"fallback_limit": limit if limit > 0 else config.HWID_FALLBACK_LIMIT,
|
|
}
|
|
|
|
|
|
def get_referral_settings() -> dict:
|
|
raw = legal.read_env_vars(["REFERRAL_ENABLED", "REFERRAL_BONUS_DAYS"])
|
|
days = _positive_int(raw.get("REFERRAL_BONUS_DAYS"), config.REFERRAL_BONUS_DAYS)
|
|
return {
|
|
"enabled": _bool(raw.get("REFERRAL_ENABLED"), config.REFERRAL_ENABLED),
|
|
"bonus_days": days if days > 0 else config.REFERRAL_BONUS_DAYS,
|
|
}
|
|
|
|
|
|
def set_referral_settings(enabled: bool, bonus_days: int):
|
|
legal.update_env_var("REFERRAL_ENABLED", "true" if enabled else "false")
|
|
legal.update_env_var("REFERRAL_BONUS_DAYS", str(int(bonus_days)))
|