system-prompts-and-models-o.../salesflow-saas/backend/app/services/affiliate_service.py

343 lines
12 KiB
Python

"""
Affiliate Service — Recruitment, commissions, career path, performance tracking.
"""
import uuid
from datetime import datetime, timezone
from decimal import Decimal
from typing import Optional
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
TIER_THRESHOLDS = {
"bronze": {"min_deals": 0, "commission_rate": 10.0},
"silver": {"min_deals": 5, "commission_rate": 12.5},
"gold": {"min_deals": 15, "commission_rate": 15.0},
"platinum": {"min_deals": 30, "commission_rate": 20.0},
}
CAREER_PATH = {
"affiliate": {"next": "senior_affiliate", "deals_required": 10, "months": 3},
"senior_affiliate": {"next": "team_lead", "deals_required": 25, "months": 6},
"team_lead": {"next": "employee", "deals_required": 50, "months": 12},
}
TEAM_LEAD_OVERRIDE_RATE = 2.5 # Extra 2.5% for team leaders on their team's sales
class AffiliateService:
"""Full affiliate lifecycle: recruitment, performance, commissions, career path."""
def __init__(self, db: AsyncSession):
self.db = db
# ── Recruitment ───────────────────────────────
async def apply(
self,
tenant_id: str,
user_id: str,
referral_code: str = None,
) -> dict:
from app.models.affiliate import Affiliate
import secrets
affiliate = Affiliate(
id=uuid.uuid4(),
tenant_id=uuid.UUID(tenant_id),
user_id=uuid.UUID(user_id),
status="applied",
tier="bronze",
referral_code=referral_code or secrets.token_urlsafe(8).upper()[:8],
commission_rate=Decimal("10.0"),
)
self.db.add(affiliate)
await self.db.flush()
return self._to_dict(affiliate)
async def approve(self, tenant_id: str, affiliate_id: str) -> Optional[dict]:
from app.models.affiliate import Affiliate
result = await self.db.execute(
select(Affiliate).where(
Affiliate.id == uuid.UUID(affiliate_id),
Affiliate.tenant_id == uuid.UUID(tenant_id),
)
)
aff = result.scalar_one_or_none()
if not aff:
return None
aff.status = "active"
aff.approved_at = datetime.now(timezone.utc)
await self.db.flush()
return self._to_dict(aff)
async def suspend(self, tenant_id: str, affiliate_id: str, reason: str = "") -> Optional[dict]:
from app.models.affiliate import Affiliate
result = await self.db.execute(
select(Affiliate).where(
Affiliate.id == uuid.UUID(affiliate_id),
Affiliate.tenant_id == uuid.UUID(tenant_id),
)
)
aff = result.scalar_one_or_none()
if not aff:
return None
aff.status = "suspended"
await self.db.flush()
return self._to_dict(aff)
# ── Commission Calculation ────────────────────
async def calculate_commission(
self,
tenant_id: str,
affiliate_id: str,
deal_id: str,
deal_value: float,
) -> dict:
from app.models.commission import Commission
from app.models.affiliate import Affiliate
result = await self.db.execute(
select(Affiliate).where(
Affiliate.id == uuid.UUID(affiliate_id),
Affiliate.tenant_id == uuid.UUID(tenant_id),
)
)
aff = result.scalar_one_or_none()
if not aff:
return {}
rate = float(aff.commission_rate)
amount = round(deal_value * rate / 100, 2)
commission = Commission(
id=uuid.uuid4(),
tenant_id=uuid.UUID(tenant_id),
affiliate_id=uuid.UUID(affiliate_id),
deal_id=uuid.UUID(deal_id),
amount=Decimal(str(amount)),
currency="SAR",
rate=aff.commission_rate,
status="pending",
period=datetime.now(timezone.utc).date().replace(day=1),
)
self.db.add(commission)
await self.db.flush()
results = [{
"commission_id": str(commission.id),
"affiliate_id": affiliate_id,
"amount": amount,
"rate": rate,
"status": "pending",
}]
# 🍯 Strategic Enhancement: Team Lead Override
if hasattr(aff, 'team_lead_id') and aff.team_lead_id:
lead_amount = round(deal_value * TEAM_LEAD_OVERRIDE_RATE / 100, 2)
lead_comm = Commission(
id=uuid.uuid4(),
tenant_id=uuid.UUID(tenant_id),
affiliate_id=aff.team_lead_id,
deal_id=uuid.UUID(deal_id),
amount=Decimal(str(lead_amount)),
currency="SAR",
rate=Decimal(str(TEAM_LEAD_OVERRIDE_RATE)),
status="pending",
period=datetime.now(timezone.utc).date().replace(day=1),
notes=f"Team override from affiliate {aff.referral_code}"
)
self.db.add(lead_comm)
results.append({
"commission_id": str(lead_comm.id),
"affiliate_id": str(aff.team_lead_id),
"amount": lead_amount,
"rate": TEAM_LEAD_OVERRIDE_RATE,
"status": "pending",
"type": "team_override"
})
await self.db.flush()
return {"commissions": results}
# ── Tier Progression ──────────────────────────
async def check_tier_upgrade(self, tenant_id: str, affiliate_id: str) -> Optional[dict]:
from app.models.affiliate import Affiliate, AffiliatePerformance
result = await self.db.execute(
select(Affiliate).where(
Affiliate.id == uuid.UUID(affiliate_id),
Affiliate.tenant_id == uuid.UUID(tenant_id),
)
)
aff = result.scalar_one_or_none()
if not aff:
return None
# Get total deals closed
perf_q = select(func.coalesce(func.sum(AffiliatePerformance.deals_closed), 0)).where(
AffiliatePerformance.affiliate_id == uuid.UUID(affiliate_id),
)
total_deals = (await self.db.execute(perf_q)).scalar() or 0
# Check upgrade
tiers = ["bronze", "silver", "gold", "platinum"]
current_idx = tiers.index(aff.tier) if aff.tier in tiers else 0
for i in range(current_idx + 1, len(tiers)):
tier = tiers[i]
if total_deals >= TIER_THRESHOLDS[tier]["min_deals"]:
aff.tier = tier
aff.commission_rate = Decimal(str(TIER_THRESHOLDS[tier]["commission_rate"]))
await self.db.flush()
return {
"upgraded": True,
"new_tier": tier,
"new_rate": TIER_THRESHOLDS[tier]["commission_rate"],
"total_deals": total_deals,
}
return {
"upgraded": False,
"current_tier": aff.tier,
"total_deals": total_deals,
"next_tier": tiers[current_idx + 1] if current_idx < len(tiers) - 1 else None,
"deals_needed": TIER_THRESHOLDS[tiers[min(current_idx + 1, len(tiers) - 1)]]["min_deals"] - total_deals,
}
# ── Career Path (Affiliate → Employee) ────────
async def check_career_path(self, tenant_id: str, affiliate_id: str) -> dict:
from app.models.affiliate import Affiliate, AffiliatePerformance
result = await self.db.execute(
select(Affiliate).where(
Affiliate.id == uuid.UUID(affiliate_id),
Affiliate.tenant_id == uuid.UUID(tenant_id),
)
)
aff = result.scalar_one_or_none()
if not aff:
return {}
perf_q = select(func.coalesce(func.sum(AffiliatePerformance.deals_closed), 0)).where(
AffiliatePerformance.affiliate_id == uuid.UUID(affiliate_id),
)
total_deals = (await self.db.execute(perf_q)).scalar() or 0
months_active = 0
if aff.approved_at:
delta = datetime.now(timezone.utc) - aff.approved_at.replace(tzinfo=timezone.utc)
months_active = delta.days // 30
# Employee eligibility
eligible = total_deals >= 50 and months_active >= 12
return {
"affiliate_id": str(affiliate_id),
"total_deals": total_deals,
"months_active": months_active,
"eligible_for_employment": eligible,
"current_tier": aff.tier,
"progress": {
"deals": {"current": total_deals, "required": 50, "percent": min(100, total_deals * 100 // 50)},
"months": {"current": months_active, "required": 12, "percent": min(100, months_active * 100 // 12)},
},
}
# ── Leaderboard ───────────────────────────────
async def get_leaderboard(self, tenant_id: str, limit: int = 20) -> list:
from app.models.affiliate import Affiliate, AffiliatePerformance
q = (
select(
Affiliate.id,
Affiliate.tier,
Affiliate.referral_code,
func.coalesce(func.sum(AffiliatePerformance.deals_closed), 0).label("total_deals"),
func.coalesce(func.sum(AffiliatePerformance.revenue_attributed), 0).label("total_revenue"),
func.coalesce(func.sum(AffiliatePerformance.commission_earned), 0).label("total_commission"),
)
.outerjoin(AffiliatePerformance, Affiliate.id == AffiliatePerformance.affiliate_id)
.where(
Affiliate.tenant_id == uuid.UUID(tenant_id),
Affiliate.status == "active",
)
.group_by(Affiliate.id, Affiliate.tier, Affiliate.referral_code)
.order_by(func.sum(AffiliatePerformance.revenue_attributed).desc().nullslast())
.limit(limit)
)
rows = (await self.db.execute(q)).all()
return [
{
"rank": i + 1,
"affiliate_id": str(row.id),
"tier": row.tier,
"referral_code": row.referral_code,
"total_deals": int(row.total_deals),
"total_revenue": float(row.total_revenue),
"total_commission": float(row.total_commission),
}
for i, row in enumerate(rows)
]
# ── Performance Summary ───────────────────────
async def get_performance(self, tenant_id: str, affiliate_id: str) -> dict:
from app.models.affiliate import AffiliatePerformance
q = select(AffiliatePerformance).where(
AffiliatePerformance.affiliate_id == uuid.UUID(affiliate_id),
).order_by(AffiliatePerformance.period.desc()).limit(12)
rows = (await self.db.execute(q)).scalars().all()
monthly = [
{
"period": row.period.isoformat() if row.period else None,
"leads_generated": row.leads_generated,
"deals_closed": row.deals_closed,
"revenue_attributed": float(row.revenue_attributed) if row.revenue_attributed else 0,
"commission_earned": float(row.commission_earned) if row.commission_earned else 0,
"conversion_rate": float(row.conversion_rate) if row.conversion_rate else 0,
}
for row in rows
]
return {
"affiliate_id": str(affiliate_id),
"monthly": monthly,
"totals": {
"leads": sum(m["leads_generated"] for m in monthly),
"deals": sum(m["deals_closed"] for m in monthly),
"revenue": sum(m["revenue_attributed"] for m in monthly),
"commission": sum(m["commission_earned"] for m in monthly),
},
}
@staticmethod
def _to_dict(aff) -> dict:
if not aff:
return {}
return {
"id": str(aff.id),
"tenant_id": str(aff.tenant_id),
"user_id": str(aff.user_id),
"status": aff.status,
"tier": aff.tier,
"referral_code": aff.referral_code,
"commission_rate": float(aff.commission_rate) if aff.commission_rate else 0,
"approved_at": aff.approved_at.isoformat() if aff.approved_at else None,
"created_at": aff.created_at.isoformat() if aff.created_at else None,
}