pounce/backend/app/services/referral_rewards.py
Yves Gugger bb7ce97330
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
Deploy: referral rewards antifraud + legal contact updates
2025-12-15 13:56:43 +01:00

246 lines
8.5 KiB
Python

"""
Referral rewards (3C.2).
Goals:
- Deterministic, abuse-resistant rewards
- No manual state tracking per referral; we compute from authoritative DB state
- Idempotent updates (can be run via scheduler and on-demand)
Current reward:
- For every N qualified referrals, grant +M bonus watchlist domain slots.
Qualified referral definition:
- referred user has `users.referred_by_user_id = referrer.id`
- referred user is_active AND is_verified
- referred user has an active subscription that is NOT Scout (Trader/Tycoon), and is currently active
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Optional
from sqlalchemy import and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.models.subscription import Subscription, SubscriptionStatus, SubscriptionTier
from app.models.telemetry import TelemetryEvent
from app.models.user import User
QUALIFIED_REFERRAL_BATCH_SIZE = 3
BONUS_DOMAINS_PER_BATCH = 5
settings = get_settings()
def compute_bonus_domains(qualified_referrals: int) -> int:
if qualified_referrals <= 0:
return 0
batches = qualified_referrals // QUALIFIED_REFERRAL_BATCH_SIZE
return int(batches * BONUS_DOMAINS_PER_BATCH)
def compute_badge(qualified_referrals: int) -> Optional[str]:
if qualified_referrals >= 10:
return "elite_referrer"
if qualified_referrals >= 3:
return "verified_referrer"
return None
@dataclass(frozen=True)
class ReferralRewardSnapshot:
referrer_user_id: int
referred_users_total: int
qualified_referrals_total: int
cooldown_days: int
disqualified_cooldown_total: int
disqualified_missing_ip_total: int
disqualified_shared_ip_total: int
disqualified_duplicate_ip_total: int
bonus_domains: int
badge: Optional[str]
computed_at: datetime
async def get_referral_reward_snapshot(db: AsyncSession, referrer_user_id: int) -> ReferralRewardSnapshot:
# Total referred users (all-time)
referred_users_total = int(
(
await db.execute(
select(func.count(User.id)).where(User.referred_by_user_id == referrer_user_id)
)
).scalar()
or 0
)
now = datetime.utcnow()
cooldown_days = max(0, int(getattr(settings, "referral_rewards_cooldown_days", 7) or 0))
cooldown_cutoff = now - timedelta(days=cooldown_days) if cooldown_days else None
# Referrer IP hashes (window) for self-ref/shared-ip checks
ip_window_days = max(1, int(getattr(settings, "referral_rewards_ip_window_days", 30) or 30))
ip_window_start = now - timedelta(days=ip_window_days)
referrer_ip_rows = (
await db.execute(
select(TelemetryEvent.ip_hash)
.where(
and_(
TelemetryEvent.user_id == referrer_user_id,
TelemetryEvent.ip_hash.isnot(None),
TelemetryEvent.created_at >= ip_window_start,
TelemetryEvent.created_at <= now,
)
)
.distinct()
)
).all()
referrer_ip_hashes = {str(r[0]) for r in referrer_ip_rows if r and r[0]}
# Referred user's registration IP hash (from telemetry) as subquery
reg_ip_subq = (
select(
TelemetryEvent.user_id.label("user_id"),
func.max(TelemetryEvent.ip_hash).label("signup_ip_hash"),
)
.where(
and_(
TelemetryEvent.event_name == "user_registered",
TelemetryEvent.user_id.isnot(None),
)
)
.group_by(TelemetryEvent.user_id)
.subquery()
)
# Candidate referred users (paid + verified + active)
rows = (
await db.execute(
select(
User.id,
User.created_at,
Subscription.started_at,
reg_ip_subq.c.signup_ip_hash,
)
.select_from(User)
.join(Subscription, Subscription.user_id == User.id)
.outerjoin(reg_ip_subq, reg_ip_subq.c.user_id == User.id)
.where(
and_(
User.referred_by_user_id == referrer_user_id,
User.is_active == True,
User.is_verified == True,
Subscription.tier.in_([SubscriptionTier.TRADER, SubscriptionTier.TYCOON]),
Subscription.status.in_([SubscriptionStatus.ACTIVE, SubscriptionStatus.PAST_DUE]),
or_(Subscription.expires_at.is_(None), Subscription.expires_at >= now),
)
)
)
).all()
require_ip = bool(getattr(settings, "referral_rewards_require_ip_hash", True))
disqualified_cooldown_total = 0
disqualified_missing_ip_total = 0
disqualified_shared_ip_total = 0
disqualified_duplicate_ip_total = 0
qualified_ip_hashes: set[str] = set()
qualified_referrals_total = 0
for _user_id, user_created_at, sub_started_at, signup_ip_hash in rows:
# Cooldown: user account age AND subscription age must pass cooldown
if cooldown_cutoff is not None:
if (user_created_at and user_created_at > cooldown_cutoff) or (
sub_started_at and sub_started_at > cooldown_cutoff
):
disqualified_cooldown_total += 1
continue
ip_hash = str(signup_ip_hash) if signup_ip_hash else None
if require_ip and not ip_hash:
disqualified_missing_ip_total += 1
continue
if ip_hash and referrer_ip_hashes and ip_hash in referrer_ip_hashes:
disqualified_shared_ip_total += 1
continue
if ip_hash and ip_hash in qualified_ip_hashes:
disqualified_duplicate_ip_total += 1
continue
if ip_hash:
qualified_ip_hashes.add(ip_hash)
qualified_referrals_total += 1
bonus_domains = compute_bonus_domains(qualified_referrals_total)
badge = compute_badge(qualified_referrals_total)
return ReferralRewardSnapshot(
referrer_user_id=referrer_user_id,
referred_users_total=referred_users_total,
qualified_referrals_total=qualified_referrals_total,
cooldown_days=cooldown_days,
disqualified_cooldown_total=disqualified_cooldown_total,
disqualified_missing_ip_total=disqualified_missing_ip_total,
disqualified_shared_ip_total=disqualified_shared_ip_total,
disqualified_duplicate_ip_total=disqualified_duplicate_ip_total,
bonus_domains=bonus_domains,
badge=badge,
computed_at=datetime.utcnow(),
)
async def apply_referral_rewards_for_user(db: AsyncSession, referrer_user_id: int) -> ReferralRewardSnapshot:
"""
Apply rewards to the referrer's subscription row, based on current qualified referrals.
This is idempotent: it sets the bonus to the computed value.
"""
snapshot = await get_referral_reward_snapshot(db, referrer_user_id)
sub_res = await db.execute(select(Subscription).where(Subscription.user_id == referrer_user_id))
sub = sub_res.scalar_one_or_none()
if not sub:
# Create default subscription so bonus can be stored
sub = Subscription(user_id=referrer_user_id, tier=SubscriptionTier.SCOUT, max_domains=5)
db.add(sub)
await db.flush()
desired = int(snapshot.bonus_domains)
current = int(getattr(sub, "referral_bonus_domains", 0) or 0)
if current != desired:
sub.referral_bonus_domains = desired
await db.flush()
return snapshot
async def apply_referral_rewards_all(db: AsyncSession) -> dict[str, int]:
"""
Apply rewards for all users that have an invite_code.
"""
res = await db.execute(select(User.id).where(User.invite_code.isnot(None)))
user_ids = [int(r[0]) for r in res.all()]
updated = 0
processed = 0
for user_id in user_ids:
processed += 1
snap = await get_referral_reward_snapshot(db, user_id)
sub_res = await db.execute(select(Subscription).where(Subscription.user_id == user_id))
sub = sub_res.scalar_one_or_none()
if not sub:
sub = Subscription(user_id=user_id, tier=SubscriptionTier.SCOUT, max_domains=5)
db.add(sub)
await db.flush()
desired = int(snap.bonus_domains)
current = int(getattr(sub, "referral_bonus_domains", 0) or 0)
if current != desired:
sub.referral_bonus_domains = desired
updated += 1
return {"processed": processed, "updated": updated}