"""Portfolio API routes.""" import secrets from datetime import datetime from typing import Optional, List from fastapi import APIRouter, Depends, HTTPException, status, Query from pydantic import BaseModel, Field from sqlalchemy import select, func, and_ from sqlalchemy.ext.asyncio import AsyncSession import dns.resolver from app.database import get_db from app.api.deps import get_current_user from app.models.user import User from app.models.portfolio import PortfolioDomain, DomainValuation from app.services.valuation import valuation_service router = APIRouter() # ============== Schemas ============== class PortfolioDomainCreate(BaseModel): """Schema for creating a portfolio domain.""" domain: str = Field(..., min_length=3, max_length=255) purchase_date: Optional[datetime] = None purchase_price: Optional[float] = Field(None, ge=0) purchase_registrar: Optional[str] = None registrar: Optional[str] = None renewal_date: Optional[datetime] = None renewal_cost: Optional[float] = Field(None, ge=0) auto_renew: bool = True notes: Optional[str] = None tags: Optional[str] = None class PortfolioDomainUpdate(BaseModel): """Schema for updating a portfolio domain.""" purchase_date: Optional[datetime] = None purchase_price: Optional[float] = Field(None, ge=0) purchase_registrar: Optional[str] = None registrar: Optional[str] = None renewal_date: Optional[datetime] = None renewal_cost: Optional[float] = Field(None, ge=0) auto_renew: Optional[bool] = None status: Optional[str] = None notes: Optional[str] = None tags: Optional[str] = None class PortfolioDomainSell(BaseModel): """Schema for marking a domain as sold.""" sale_date: datetime sale_price: float = Field(..., ge=0) class PortfolioDomainResponse(BaseModel): """Response schema for portfolio domain.""" id: int domain: str purchase_date: Optional[datetime] purchase_price: Optional[float] purchase_registrar: Optional[str] registrar: Optional[str] renewal_date: Optional[datetime] renewal_cost: Optional[float] auto_renew: bool estimated_value: Optional[float] value_updated_at: Optional[datetime] is_sold: bool sale_date: Optional[datetime] sale_price: Optional[float] status: str notes: Optional[str] tags: Optional[str] roi: Optional[float] # DNS Verification fields is_dns_verified: bool = False verification_status: str = "unverified" verification_code: Optional[str] = None verified_at: Optional[datetime] = None created_at: datetime updated_at: datetime class Config: from_attributes = True class DNSVerificationStartResponse(BaseModel): """Response when starting DNS verification.""" domain_id: int domain: str verification_code: str dns_record_type: str dns_record_name: str dns_record_value: str instructions: str status: str class DNSVerificationCheckResponse(BaseModel): """Response when checking DNS verification.""" verified: bool status: str message: str class PortfolioSummary(BaseModel): """Summary of user's portfolio.""" total_domains: int active_domains: int sold_domains: int total_invested: float total_value: float total_sold_value: float unrealized_profit: float realized_profit: float overall_roi: float class ValuationScores(BaseModel): """Domain valuation scores breakdown.""" length: int tld: int keyword: int brandability: int overall: int class ValuationFactors(BaseModel): """Domain valuation factors.""" length: int tld: str has_numbers: bool has_hyphens: bool is_dictionary_word: bool detected_keywords: List[str] = [] class ValuationCalculation(BaseModel): """Transparent calculation breakdown.""" base_value: float length_factor: float length_reason: str tld_factor: float tld_reason: str keyword_factor: float keyword_reason: str brand_factor: float brand_reason: str formula: str raw_result: float class RegistrationContext(BaseModel): """TLD registration cost context.""" tld_cost: Optional[float] = None value_to_cost_ratio: Optional[float] = None class ValuationResponse(BaseModel): """Response schema for domain valuation - fully transparent.""" domain: str estimated_value: float currency: str confidence: str # Detailed breakdowns scores: ValuationScores factors: ValuationFactors calculation: ValuationCalculation registration_context: RegistrationContext # Metadata source: str calculated_at: str disclaimer: str # ============== Portfolio Endpoints ============== @router.get("", response_model=List[PortfolioDomainResponse]) async def get_portfolio( status: Optional[str] = Query(None, description="Filter by status"), sort_by: str = Query("created_at", description="Sort field"), sort_order: str = Query("desc", description="Sort order (asc/desc)"), limit: int = Query(100, le=500), offset: int = Query(0, ge=0), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Get user's portfolio domains.""" query = select(PortfolioDomain).where(PortfolioDomain.user_id == current_user.id) # Filter by status if status: query = query.where(PortfolioDomain.status == status) # Sorting sort_column = getattr(PortfolioDomain, sort_by, PortfolioDomain.created_at) if sort_order == "asc": query = query.order_by(sort_column.asc()) else: query = query.order_by(sort_column.desc()) # Pagination query = query.offset(offset).limit(limit) result = await db.execute(query) domains = result.scalars().all() # Calculate ROI for each domain responses = [] for d in domains: response = PortfolioDomainResponse( id=d.id, domain=d.domain, purchase_date=d.purchase_date, purchase_price=d.purchase_price, purchase_registrar=d.purchase_registrar, registrar=d.registrar, renewal_date=d.renewal_date, renewal_cost=d.renewal_cost, auto_renew=d.auto_renew, estimated_value=d.estimated_value, value_updated_at=d.value_updated_at, is_sold=d.is_sold, sale_date=d.sale_date, sale_price=d.sale_price, status=d.status, notes=d.notes, tags=d.tags, roi=d.roi, is_dns_verified=d.is_dns_verified, verification_status=d.verification_status, verification_code=d.verification_code, verified_at=d.verified_at, created_at=d.created_at, updated_at=d.updated_at, ) responses.append(response) return responses @router.get("/summary", response_model=PortfolioSummary) async def get_portfolio_summary( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Get portfolio summary statistics.""" result = await db.execute( select(PortfolioDomain).where(PortfolioDomain.user_id == current_user.id) ) domains = result.scalars().all() total_domains = len(domains) active_domains = sum(1 for d in domains if d.status == "active" and not d.is_sold) sold_domains = sum(1 for d in domains if d.is_sold) total_invested = sum(d.purchase_price or 0 for d in domains) total_value = sum(d.estimated_value or 0 for d in domains if not d.is_sold) total_sold_value = sum(d.sale_price or 0 for d in domains if d.is_sold) # Calculate active investment for ROI active_investment = sum(d.purchase_price or 0 for d in domains if not d.is_sold) sold_investment = sum(d.purchase_price or 0 for d in domains if d.is_sold) unrealized_profit = total_value - active_investment realized_profit = total_sold_value - sold_investment overall_roi = 0.0 if total_invested > 0: overall_roi = ((total_value + total_sold_value - total_invested) / total_invested) * 100 return PortfolioSummary( total_domains=total_domains, active_domains=active_domains, sold_domains=sold_domains, total_invested=round(total_invested, 2), total_value=round(total_value, 2), total_sold_value=round(total_sold_value, 2), unrealized_profit=round(unrealized_profit, 2), realized_profit=round(realized_profit, 2), overall_roi=round(overall_roi, 2), ) @router.post("", response_model=PortfolioDomainResponse, status_code=status.HTTP_201_CREATED) async def add_portfolio_domain( data: PortfolioDomainCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Add a domain to portfolio.""" from app.models.subscription import Subscription, SubscriptionTier, TIER_CONFIG # Check subscription portfolio limit await db.refresh(current_user, ["subscription"]) if current_user.subscription: portfolio_limit = current_user.subscription.portfolio_limit else: portfolio_limit = TIER_CONFIG[SubscriptionTier.SCOUT].get("portfolio_limit", 0) # Count current portfolio domains count_result = await db.execute( select(func.count(PortfolioDomain.id)).where( PortfolioDomain.user_id == current_user.id ) ) current_count = count_result.scalar() or 0 # Check limit (-1 means unlimited) if portfolio_limit != -1 and current_count >= portfolio_limit: if portfolio_limit == 0: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Portfolio feature not available on Scout plan. Upgrade to Trader or Tycoon.", ) raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"Portfolio limit reached ({portfolio_limit} domains). Upgrade to add more.", ) # Check if domain already exists in user's portfolio existing = await db.execute( select(PortfolioDomain).where( and_( PortfolioDomain.user_id == current_user.id, PortfolioDomain.domain == data.domain.lower(), ) ) ) if existing.scalar_one_or_none(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Domain already in portfolio", ) # Get initial valuation valuation = await valuation_service.estimate_value(data.domain, db, save_result=True) estimated_value = valuation.get("estimated_value") if "error" not in valuation else None # Create portfolio entry domain = PortfolioDomain( user_id=current_user.id, domain=data.domain.lower(), purchase_date=data.purchase_date, purchase_price=data.purchase_price, purchase_registrar=data.purchase_registrar, registrar=data.registrar or data.purchase_registrar, renewal_date=data.renewal_date, renewal_cost=data.renewal_cost, auto_renew=data.auto_renew, estimated_value=estimated_value, value_updated_at=datetime.utcnow() if estimated_value else None, notes=data.notes, tags=data.tags, ) db.add(domain) await db.commit() await db.refresh(domain) return PortfolioDomainResponse( id=domain.id, domain=domain.domain, purchase_date=domain.purchase_date, purchase_price=domain.purchase_price, purchase_registrar=domain.purchase_registrar, registrar=domain.registrar, renewal_date=domain.renewal_date, renewal_cost=domain.renewal_cost, auto_renew=domain.auto_renew, estimated_value=domain.estimated_value, value_updated_at=domain.value_updated_at, is_sold=domain.is_sold, sale_date=domain.sale_date, sale_price=domain.sale_price, status=domain.status, notes=domain.notes, tags=domain.tags, roi=domain.roi, is_dns_verified=domain.is_dns_verified, verification_status=domain.verification_status, verification_code=domain.verification_code, verified_at=domain.verified_at, created_at=domain.created_at, updated_at=domain.updated_at, ) @router.get("/{domain_id}", response_model=PortfolioDomainResponse) async def get_portfolio_domain( domain_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Get a specific portfolio domain.""" result = await db.execute( select(PortfolioDomain).where( and_( PortfolioDomain.id == domain_id, PortfolioDomain.user_id == current_user.id, ) ) ) domain = result.scalar_one_or_none() if not domain: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Domain not found in portfolio", ) return PortfolioDomainResponse( id=domain.id, domain=domain.domain, purchase_date=domain.purchase_date, purchase_price=domain.purchase_price, purchase_registrar=domain.purchase_registrar, registrar=domain.registrar, renewal_date=domain.renewal_date, renewal_cost=domain.renewal_cost, auto_renew=domain.auto_renew, estimated_value=domain.estimated_value, value_updated_at=domain.value_updated_at, is_sold=domain.is_sold, sale_date=domain.sale_date, sale_price=domain.sale_price, status=domain.status, notes=domain.notes, tags=domain.tags, roi=domain.roi, is_dns_verified=domain.is_dns_verified, verification_status=domain.verification_status, verification_code=domain.verification_code, verified_at=domain.verified_at, created_at=domain.created_at, updated_at=domain.updated_at, ) @router.put("/{domain_id}", response_model=PortfolioDomainResponse) async def update_portfolio_domain( domain_id: int, data: PortfolioDomainUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Update a portfolio domain.""" result = await db.execute( select(PortfolioDomain).where( and_( PortfolioDomain.id == domain_id, PortfolioDomain.user_id == current_user.id, ) ) ) domain = result.scalar_one_or_none() if not domain: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Domain not found in portfolio", ) # Update fields update_data = data.model_dump(exclude_unset=True) for field, value in update_data.items(): setattr(domain, field, value) await db.commit() await db.refresh(domain) return PortfolioDomainResponse( id=domain.id, domain=domain.domain, purchase_date=domain.purchase_date, purchase_price=domain.purchase_price, purchase_registrar=domain.purchase_registrar, registrar=domain.registrar, renewal_date=domain.renewal_date, renewal_cost=domain.renewal_cost, auto_renew=domain.auto_renew, estimated_value=domain.estimated_value, value_updated_at=domain.value_updated_at, is_sold=domain.is_sold, sale_date=domain.sale_date, sale_price=domain.sale_price, status=domain.status, notes=domain.notes, tags=domain.tags, roi=domain.roi, is_dns_verified=domain.is_dns_verified, verification_status=domain.verification_status, verification_code=domain.verification_code, verified_at=domain.verified_at, created_at=domain.created_at, updated_at=domain.updated_at, ) @router.post("/{domain_id}/sell", response_model=PortfolioDomainResponse) async def mark_domain_sold( domain_id: int, data: PortfolioDomainSell, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Mark a domain as sold.""" result = await db.execute( select(PortfolioDomain).where( and_( PortfolioDomain.id == domain_id, PortfolioDomain.user_id == current_user.id, ) ) ) domain = result.scalar_one_or_none() if not domain: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Domain not found in portfolio", ) domain.is_sold = True domain.sale_date = data.sale_date domain.sale_price = data.sale_price domain.status = "sold" await db.commit() await db.refresh(domain) return PortfolioDomainResponse( id=domain.id, domain=domain.domain, purchase_date=domain.purchase_date, purchase_price=domain.purchase_price, purchase_registrar=domain.purchase_registrar, registrar=domain.registrar, renewal_date=domain.renewal_date, renewal_cost=domain.renewal_cost, auto_renew=domain.auto_renew, estimated_value=domain.estimated_value, value_updated_at=domain.value_updated_at, is_sold=domain.is_sold, sale_date=domain.sale_date, sale_price=domain.sale_price, status=domain.status, notes=domain.notes, tags=domain.tags, roi=domain.roi, is_dns_verified=domain.is_dns_verified, verification_status=domain.verification_status, verification_code=domain.verification_code, verified_at=domain.verified_at, created_at=domain.created_at, updated_at=domain.updated_at, ) @router.delete("/{domain_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_portfolio_domain( domain_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Delete a domain from portfolio.""" result = await db.execute( select(PortfolioDomain).where( and_( PortfolioDomain.id == domain_id, PortfolioDomain.user_id == current_user.id, ) ) ) domain = result.scalar_one_or_none() if not domain: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Domain not found in portfolio", ) await db.delete(domain) await db.commit() @router.post("/{domain_id}/refresh-value", response_model=PortfolioDomainResponse) async def refresh_domain_value( domain_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Refresh the estimated value of a portfolio domain.""" result = await db.execute( select(PortfolioDomain).where( and_( PortfolioDomain.id == domain_id, PortfolioDomain.user_id == current_user.id, ) ) ) domain = result.scalar_one_or_none() if not domain: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Domain not found in portfolio", ) # Get new valuation valuation = await valuation_service.estimate_value(domain.domain, db, save_result=True) if "error" not in valuation: domain.estimated_value = valuation["estimated_value"] domain.value_updated_at = datetime.utcnow() await db.commit() await db.refresh(domain) return PortfolioDomainResponse( id=domain.id, domain=domain.domain, purchase_date=domain.purchase_date, purchase_price=domain.purchase_price, purchase_registrar=domain.purchase_registrar, registrar=domain.registrar, renewal_date=domain.renewal_date, renewal_cost=domain.renewal_cost, auto_renew=domain.auto_renew, estimated_value=domain.estimated_value, value_updated_at=domain.value_updated_at, is_sold=domain.is_sold, sale_date=domain.sale_date, sale_price=domain.sale_price, status=domain.status, notes=domain.notes, tags=domain.tags, roi=domain.roi, is_dns_verified=domain.is_dns_verified, verification_status=domain.verification_status, verification_code=domain.verification_code, verified_at=domain.verified_at, created_at=domain.created_at, updated_at=domain.updated_at, ) # ============== Valuation Endpoints ============== @router.get("/valuation/{domain}", response_model=ValuationResponse) async def get_domain_valuation( domain: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Get estimated value for any domain.""" valuation = await valuation_service.estimate_value(domain, db, save_result=True) if "error" in valuation: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=valuation["error"], ) return ValuationResponse(**valuation) # ============== DNS Verification Endpoints ============== def _generate_verification_code() -> str: """Generate a unique verification code.""" return f"pounce-verify-{secrets.token_hex(8)}" def _domain_to_response(domain: PortfolioDomain) -> PortfolioDomainResponse: """Convert PortfolioDomain to response schema.""" return PortfolioDomainResponse( id=domain.id, domain=domain.domain, purchase_date=domain.purchase_date, purchase_price=domain.purchase_price, purchase_registrar=domain.purchase_registrar, registrar=domain.registrar, renewal_date=domain.renewal_date, renewal_cost=domain.renewal_cost, auto_renew=domain.auto_renew, estimated_value=domain.estimated_value, value_updated_at=domain.value_updated_at, is_sold=domain.is_sold, sale_date=domain.sale_date, sale_price=domain.sale_price, status=domain.status, notes=domain.notes, tags=domain.tags, roi=domain.roi, is_dns_verified=domain.is_dns_verified, verification_status=domain.verification_status, verification_code=domain.verification_code, verified_at=domain.verified_at, created_at=domain.created_at, updated_at=domain.updated_at, ) @router.post("/{domain_id}/verify-dns", response_model=DNSVerificationStartResponse) async def start_dns_verification( domain_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """ Start DNS verification for a portfolio domain. Returns a verification code that must be added as a TXT record. """ result = await db.execute( select(PortfolioDomain).where( and_( PortfolioDomain.id == domain_id, PortfolioDomain.user_id == current_user.id, ) ) ) domain = result.scalar_one_or_none() if not domain: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Domain not found in portfolio", ) if domain.is_dns_verified: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Domain is already verified", ) # Generate or reuse existing verification code if not domain.verification_code: domain.verification_code = _generate_verification_code() domain.verification_status = "pending" domain.verification_started_at = datetime.utcnow() await db.commit() await db.refresh(domain) return DNSVerificationStartResponse( domain_id=domain.id, domain=domain.domain, verification_code=domain.verification_code, dns_record_type="TXT", dns_record_name=f"_pounce.{domain.domain}", dns_record_value=domain.verification_code, instructions=f"Add a TXT record to your DNS settings:\n\nHost/Name: _pounce\nType: TXT\nValue: {domain.verification_code}\n\nDNS changes can take up to 48 hours to propagate, but usually complete within minutes.", status=domain.verification_status, ) @router.get("/{domain_id}/verify-dns/check", response_model=DNSVerificationCheckResponse) async def check_dns_verification( domain_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """ Check if DNS verification is complete. Looks for the TXT record and verifies it matches the expected code. """ result = await db.execute( select(PortfolioDomain).where( and_( PortfolioDomain.id == domain_id, PortfolioDomain.user_id == current_user.id, ) ) ) domain = result.scalar_one_or_none() if not domain: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Domain not found in portfolio", ) if domain.is_dns_verified: return DNSVerificationCheckResponse( verified=True, status="verified", message="Domain ownership already verified", ) if not domain.verification_code: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Verification not started. Call POST /verify-dns first.", ) # Check DNS TXT record txt_record_name = f"_pounce.{domain.domain}" verified = False try: resolver = dns.resolver.Resolver() resolver.timeout = 5 resolver.lifetime = 10 answers = resolver.resolve(txt_record_name, 'TXT') for rdata in answers: txt_value = rdata.to_text().strip('"') if txt_value == domain.verification_code: verified = True break except dns.resolver.NXDOMAIN: return DNSVerificationCheckResponse( verified=False, status="pending", message=f"TXT record not found. Please add a TXT record at _pounce.{domain.domain}", ) except dns.resolver.NoAnswer: return DNSVerificationCheckResponse( verified=False, status="pending", message="TXT record exists but has no value. Check your DNS configuration.", ) except dns.resolver.Timeout: return DNSVerificationCheckResponse( verified=False, status="pending", message="DNS query timed out. Please try again.", ) except Exception as e: return DNSVerificationCheckResponse( verified=False, status="error", message=f"DNS lookup error: {str(e)}", ) if verified: domain.is_dns_verified = True domain.verification_status = "verified" domain.verified_at = datetime.utcnow() await db.commit() return DNSVerificationCheckResponse( verified=True, status="verified", message="Domain ownership verified successfully! You can now list this domain for sale or activate Yield.", ) else: return DNSVerificationCheckResponse( verified=False, status="pending", message=f"TXT record found but value doesn't match. Expected: {domain.verification_code}", ) @router.get("/verified", response_model=List[PortfolioDomainResponse]) async def get_verified_domains( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """ Get only DNS-verified portfolio domains. These domains can be used for Yield or For Sale listings. """ result = await db.execute( select(PortfolioDomain).where( and_( PortfolioDomain.user_id == current_user.id, PortfolioDomain.is_dns_verified == True, PortfolioDomain.is_sold == False, ) ).order_by(PortfolioDomain.domain.asc()) ) domains = result.scalars().all() return [_domain_to_response(d) for d in domains]