"""CFO (Management) endpoints.""" from __future__ import annotations from datetime import datetime, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException, Request, status from slowapi import Limiter from slowapi.util import get_remote_address from sqlalchemy import and_, case, func, select from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_user from app.database import get_db from app.models.portfolio import PortfolioDomain from app.models.user import User from app.models.yield_domain import YieldDomain, YieldTransaction from app.schemas.cfo import ( CfoKillListRow, CfoMonthlyBucket, CfoSummaryResponse, CfoUpcomingCostRow, SetToDropResponse, ) from app.services.analyze.renewal_cost import get_tld_price_snapshot router = APIRouter() limiter = Limiter(key_func=get_remote_address) def _utcnow() -> datetime: return datetime.now(timezone.utc) def _month_key(dt: datetime) -> str: return f"{dt.year:04d}-{dt.month:02d}" async def _estimate_renewal_cost_usd(db: AsyncSession, domain: str) -> tuple[float | None, str]: # If the user stored renewal_cost, we treat it as the source of truth. # Else we estimate using our own collected `tld_prices` DB. tld = domain.split(".")[-1].lower() snap = await get_tld_price_snapshot(db, tld) if snap.min_renew_usd is None: return None, "unknown" return float(snap.min_renew_usd), "tld_prices" @router.get("/summary", response_model=CfoSummaryResponse) @limiter.limit("30/minute") async def cfo_summary( request: Request, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """ CFO dashboard summary: - Burn rate timeline (renewal costs) - Upcoming costs (30d) - Kill list (renewal soon + no yield signals) """ now = _utcnow() now_naive = now.replace(tzinfo=None) domains = ( await db.execute(select(PortfolioDomain).where(PortfolioDomain.user_id == current_user.id)) ).scalars().all() # Yield stats (last 60d) by domain since_60d = now_naive - timedelta(days=60) yd_rows = ( await db.execute( select( YieldDomain.domain, func.coalesce(func.sum(YieldTransaction.net_amount), 0).label("net_sum"), func.coalesce(func.sum(case((YieldTransaction.event_type == "click", 1), else_=0)), 0).label("clicks"), ) .join( YieldTransaction, and_(YieldTransaction.yield_domain_id == YieldDomain.id, YieldTransaction.created_at >= since_60d), isouter=True, ) .where(YieldDomain.user_id == current_user.id) .group_by(YieldDomain.domain) ) ).all() yield_by_domain = {str(d).lower(): {"net": float(n or 0), "clicks": int(c or 0)} for d, n, c in yd_rows} # Monthly buckets next 12 months buckets: dict[str, CfoMonthlyBucket] = {} for i in range(0, 12): d = (now + timedelta(days=30 * i)).replace(day=1) buckets[_month_key(d)] = CfoMonthlyBucket(month=_month_key(d), total_cost_usd=0.0, domains=0) upcoming_rows: list[CfoUpcomingCostRow] = [] kill_list: list[CfoKillListRow] = [] cutoff_30d = now_naive + timedelta(days=30) for pd in domains: if pd.is_sold: continue renewal_dt = pd.renewal_date if not renewal_dt: continue if renewal_dt.tzinfo is not None: renewal_dt_naive = renewal_dt.astimezone(timezone.utc).replace(tzinfo=None) else: renewal_dt_naive = renewal_dt # cost source: portfolio overrides if pd.renewal_cost is not None: cost = float(pd.renewal_cost) source = "portfolio" else: cost, source = await _estimate_renewal_cost_usd(db, pd.domain) # Monthly burn timeline month = _month_key(renewal_dt_naive) if month not in buckets: buckets[month] = CfoMonthlyBucket(month=month, total_cost_usd=0.0, domains=0) if cost is not None: buckets[month].total_cost_usd = float(buckets[month].total_cost_usd) + float(cost) buckets[month].domains = int(buckets[month].domains) + 1 # Upcoming 30d if now_naive <= renewal_dt_naive <= cutoff_30d: upcoming_rows.append( CfoUpcomingCostRow( domain_id=pd.id, domain=pd.domain, renewal_date=renewal_dt, renewal_cost_usd=cost, cost_source=source, is_sold=bool(pd.is_sold), ) ) y = yield_by_domain.get(pd.domain.lower(), {"net": 0.0, "clicks": 0}) if float(y["net"]) <= 0.0 and int(y["clicks"]) <= 0: kill_list.append( CfoKillListRow( domain_id=pd.id, domain=pd.domain, renewal_date=renewal_dt, renewal_cost_usd=cost, cost_source=source, auto_renew=bool(pd.auto_renew), is_dns_verified=bool(getattr(pd, "is_dns_verified", False) or False), yield_net_60d=float(y["net"]), yield_clicks_60d=int(y["clicks"]), reason="No yield signals tracked in the last 60 days and renewal is due within 30 days.", ) ) # Sort rows upcoming_rows.sort(key=lambda r: (r.renewal_date or now_naive)) kill_list.sort(key=lambda r: (r.renewal_date or now_naive)) upcoming_total = sum((r.renewal_cost_usd or 0) for r in upcoming_rows) monthly_sorted = [buckets[k] for k in sorted(buckets.keys())] return CfoSummaryResponse( computed_at=now, upcoming_30d_total_usd=float(round(upcoming_total, 2)), upcoming_30d_rows=upcoming_rows, monthly=monthly_sorted, kill_list=kill_list[:50], ) @router.post("/domains/{domain_id}/set-to-drop", response_model=SetToDropResponse) @limiter.limit("30/minute") async def set_to_drop( request: Request, domain_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """ Mark portfolio domain as 'to drop' by turning off local auto-renew flag. (We cannot disable auto-renew at the registrar automatically.) """ pd = ( await db.execute( select(PortfolioDomain).where(and_(PortfolioDomain.id == domain_id, PortfolioDomain.user_id == current_user.id)) ) ).scalar_one_or_none() if not pd: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Portfolio domain not found") pd.auto_renew = False pd.updated_at = datetime.utcnow() await db.commit() return SetToDropResponse(domain_id=pd.id, auto_renew=bool(pd.auto_renew), updated_at=pd.updated_at.replace(tzinfo=timezone.utc))