pounce/backend/app/models/subscription.py
Yves Gugger dad97f951e
Some checks are pending
CI / Frontend Lint & Type Check (push) Waiting to run
CI / Frontend Build (push) Blocked by required conditions
CI / Backend Lint (push) Waiting to run
CI / Backend Tests (push) Blocked by required conditions
CI / Docker Build (push) Blocked by required conditions
CI / Security Scan (push) Waiting to run
Deploy / Build & Push Images (push) Waiting to run
Deploy / Deploy to Server (push) Blocked by required conditions
Deploy / Notify (push) Blocked by required conditions
fix: Scout tier restrictions - no listings, no sniper alerts
Frontend:
- Scout: Listings and Sniper marked as unavailable
- Updated comparison table

Backend TIER_CONFIG:
- Scout: domain_limit=5, portfolio_limit=5, listing_limit=0, sniper_limit=0
- Trader: domain_limit=50, portfolio_limit=50
2025-12-18 14:47:25 +01:00

206 lines
7.2 KiB
Python

"""Subscription model."""
from datetime import datetime
from enum import Enum
from typing import Optional
from sqlalchemy import String, DateTime, ForeignKey, Integer, Boolean, Enum as SQLEnum
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class SubscriptionTier(str, Enum):
"""
Subscription tiers for pounce.ch
Scout (Free): 10 watchlist, 3 portfolio, 1 listing, daily checks
Trader ($9/mo): 100 watchlist, 50 portfolio, 10 listings, hourly checks
Tycoon ($29/mo): Unlimited, 5-min checks, API, bulk tools, exclusive drops
"""
SCOUT = "scout" # Free tier
TRADER = "trader" # $9/month
TYCOON = "tycoon" # $29/month
class SubscriptionStatus(str, Enum):
"""Subscription status."""
ACTIVE = "active"
CANCELLED = "cancelled"
EXPIRED = "expired"
PENDING = "pending"
PAST_DUE = "past_due"
# Plan configuration - matches frontend pricing page
# Updated 2024: Better conversion funnel with taste-before-pay model
TIER_CONFIG = {
SubscriptionTier.SCOUT: {
"name": "Scout",
"price": 0,
"currency": "USD",
"domain_limit": 5, # Watchlist: 5
"portfolio_limit": 5, # Portfolio: 5
"listing_limit": 0, # Listings: 0 (Trader+ only)
"sniper_limit": 0, # Sniper alerts: 0 (Trader+ only)
"check_frequency": "daily",
"history_days": 7,
"features": {
"email_alerts": True,
"sms_alerts": False,
"priority_alerts": False,
"full_whois": False,
"expiration_tracking": False,
"domain_valuation": True, # Basic score enabled
"market_insights": False,
"api_access": False,
"webhooks": False,
"bulk_tools": False,
"seo_metrics": False,
"yield": False,
"daily_drop_digest": False,
}
},
SubscriptionTier.TRADER: {
"name": "Trader",
"price": 9,
"currency": "USD",
"domain_limit": 50, # Watchlist: 50
"portfolio_limit": 50, # Portfolio: 50
"listing_limit": 10, # Listings: 10
"sniper_limit": 10, # Sniper alerts: 10
"check_frequency": "hourly",
"history_days": 90,
"features": {
"email_alerts": True,
"sms_alerts": True,
"priority_alerts": True,
"full_whois": True,
"expiration_tracking": True,
"domain_valuation": True,
"market_insights": True,
"api_access": False,
"webhooks": False,
"bulk_tools": False,
"seo_metrics": False,
# Yield Preview only - can see landing page but not activate routing
"yield": False,
"daily_drop_digest": False,
}
},
SubscriptionTier.TYCOON: {
"name": "Tycoon",
"price": 29,
"currency": "USD",
"domain_limit": -1, # Unlimited watchlist
"portfolio_limit": -1, # Unlimited portfolio
"listing_limit": -1, # Unlimited listings
"sniper_limit": 50, # Sniper alerts
"check_frequency": "5min", # Every 5 minutes (was 10min)
"history_days": -1, # Unlimited
"features": {
"email_alerts": True,
"sms_alerts": True,
"priority_alerts": True,
"full_whois": True,
"expiration_tracking": True,
"domain_valuation": True,
"market_insights": True,
"api_access": True,
"webhooks": True,
"bulk_tools": True,
"seo_metrics": True,
"yield": True,
"daily_drop_digest": True, # Tycoon exclusive: Curated top 10 drops daily
}
},
}
class Subscription(Base):
"""
Subscription model for tracking user plans.
Integrates with Stripe for payment processing.
"""
__tablename__ = "subscriptions"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), unique=True, nullable=False)
# Plan details
tier: Mapped[SubscriptionTier] = mapped_column(
SQLEnum(SubscriptionTier), default=SubscriptionTier.SCOUT
)
status: Mapped[SubscriptionStatus] = mapped_column(
SQLEnum(SubscriptionStatus), default=SubscriptionStatus.ACTIVE
)
# Limits (can be overridden)
max_domains: Mapped[int] = mapped_column(Integer, default=5)
# Referral reward bonus (3C.2): additive, computed deterministically from qualified referrals
referral_bonus_domains: Mapped[int] = mapped_column(Integer, default=0)
check_frequency: Mapped[str] = mapped_column(String(50), default="daily")
# Stripe integration
stripe_subscription_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
# Legacy payment reference (for migration)
payment_reference: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
# Dates
started_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
cancelled_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationship
user: Mapped["User"] = relationship("User", back_populates="subscription")
@property
def is_active(self) -> bool:
"""Check if subscription is currently active."""
if self.status not in [SubscriptionStatus.ACTIVE, SubscriptionStatus.PAST_DUE]:
return False
if self.expires_at and self.expires_at < datetime.utcnow():
return False
return True
@property
def config(self) -> dict:
"""Get configuration for this subscription tier."""
return TIER_CONFIG.get(self.tier, TIER_CONFIG[SubscriptionTier.SCOUT])
@property
def tier_name(self) -> str:
"""Get human-readable tier name."""
return self.config["name"]
@property
def price(self) -> float:
"""Get price for this tier."""
return self.config["price"]
@property
def domain_limit(self) -> int:
"""Get maximum allowed domains for this subscription."""
base = int(self.max_domains or self.config["domain_limit"] or 0)
bonus = int(self.referral_bonus_domains or 0)
return max(0, base + bonus)
@property
def portfolio_limit(self) -> int:
"""Get maximum portfolio domains. -1 = unlimited."""
return self.config.get("portfolio_limit", 0)
@property
def history_days(self) -> int:
"""Get history retention days. -1 = unlimited."""
return self.config["history_days"]
def has_feature(self, feature: str) -> bool:
"""Check if subscription has a specific feature."""
return self.config.get("features", {}).get(feature, False)
def __repr__(self) -> str:
return f"<Subscription {self.tier.value} for user {self.user_id}>"