Spaces:
Sleeping
Sleeping
File size: 9,353 Bytes
cfe45d5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 | """
Subscription plan & subscriber models.
SubscriptionPlan β defines the available plans (Free, Per-Video, Pro Monthly, Pro Annual)
Subscription β tracks a user's active/past subscriptions and per-video purchases
"""
import enum
from datetime import datetime
from sqlalchemy import (
String, Text, Enum, DateTime, ForeignKey,
Integer, Boolean, Float, UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
# βββ Plan Definitions ββββββββββββββββββββββββββββββββββββββ
class BillingInterval(str, enum.Enum):
ONE_TIME = "one_time"
MONTHLY = "monthly"
ANNUAL = "annual"
class SubscriptionPlan(Base):
"""
Canonical source of truth for every purchasable plan.
Seeded on app startup β never deleted, only soft-updated.
"""
__tablename__ = "subscription_plans"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
slug: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
# Pricing
price_cents: Mapped[int] = mapped_column(Integer, nullable=False) # e.g. 5_00 = $5
currency: Mapped[str] = mapped_column(String(3), default="usd")
billing_interval: Mapped[BillingInterval] = mapped_column(
Enum(BillingInterval), nullable=False
)
# Limits & features
video_limit: Mapped[int] = mapped_column(Integer, default=0)
# -1 means "per-purchase" (1 video unlocked per payment)
# 0 means "no videos included" (shouldn't happen)
# N means "N videos per period"
includes_studio: Mapped[bool] = mapped_column(Boolean, default=False)
includes_chat_editor: Mapped[bool] = mapped_column(Boolean, default=False)
includes_priority_support: Mapped[bool] = mapped_column(Boolean, default=False)
# Stripe mapping
stripe_price_id: Mapped[str | None] = mapped_column(String(255), nullable=True, unique=True)
# Display
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
subscriptions: Mapped[list["Subscription"]] = relationship(
"Subscription", back_populates="plan", cascade="all, delete-orphan"
)
def __repr__(self) -> str:
return f"<SubscriptionPlan {self.slug} ${self.price_cents / 100:.2f}/{self.billing_interval.value}>"
# βββ Subscriptions / Purchases βββββββββββββββββββββββββββββ
class SubscriptionStatus(str, enum.Enum):
ACTIVE = "active"
PAST_DUE = "past_due"
CANCELED = "canceled"
EXPIRED = "expired"
COMPLETED = "completed" # one-time purchases that are fulfilled
REQUIRES_ACTION = "requires_action" # 3D Secure / SCA pending
class Subscription(Base):
"""
Tracks every purchase or subscription a user makes.
- One-time ($5 per-video): status goes straight to COMPLETED after payment.
- Recurring (Pro monthly/annual): status tracks the Stripe subscription lifecycle.
"""
__tablename__ = "subscriptions"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
plan_id: Mapped[int] = mapped_column(ForeignKey("subscription_plans.id"), nullable=False, index=True)
status: Mapped[SubscriptionStatus] = mapped_column(
Enum(SubscriptionStatus), default=SubscriptionStatus.ACTIVE
)
# Stripe references
stripe_subscription_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
stripe_checkout_session_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
# For per-video purchases β links to the specific project
project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id"), nullable=True, index=True)
# Billing period (for recurring)
current_period_start: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
current_period_end: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Usage tracking (for recurring plans with video limits)
videos_used: Mapped[int] = mapped_column(Integer, default=0)
# Payment
amount_paid_cents: Mapped[int] = mapped_column(Integer, default=0)
canceled_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
user: Mapped["User"] = relationship("User", back_populates="subscriptions")
plan: Mapped["SubscriptionPlan"] = relationship("SubscriptionPlan", back_populates="subscriptions")
def __repr__(self) -> str:
return f"<Subscription #{self.id} user={self.user_id} plan={self.plan_id} status={self.status.value}>"
# βββ Seed data βββββββββββββββββββββββββββββββββββββββββββββ
SEED_PLANS = [
{
"slug": "free",
"name": "Free",
"description": "First video free β no credit card needed",
"price_cents": 0,
"billing_interval": BillingInterval.ONE_TIME,
"video_limit": 1,
"includes_studio": False,
"includes_chat_editor": False,
"includes_priority_support": False,
"stripe_price_id": None,
"sort_order": 0,
},
{
"slug": "per_video",
"name": "Per Video",
"description": "Pay $5 per video β includes Studio & AI chat editor",
"price_cents": 500,
"billing_interval": BillingInterval.ONE_TIME,
"video_limit": -1, # 1 video per purchase
"includes_studio": True,
"includes_chat_editor": True,
"includes_priority_support": False,
"stripe_price_id": None, # Set from STRIPE_PER_VIDEO_PRICE_ID
"sort_order": 1,
},
{
"slug": "pro_monthly",
"name": "Pro Monthly",
"description": "100 videos/month with all features",
"price_cents": 5000,
"billing_interval": BillingInterval.MONTHLY,
"video_limit": 100,
"includes_studio": True,
"includes_chat_editor": True,
"includes_priority_support": True,
"stripe_price_id": None, # Set from STRIPE_PRO_PRICE_ID
"sort_order": 2,
},
{
"slug": "pro_annual",
"name": "Pro Annual",
"description": "100 videos/month β save 20% with annual billing",
"price_cents": 4000, # $40/mo effective
"billing_interval": BillingInterval.ANNUAL,
"video_limit": 100,
"includes_studio": True,
"includes_chat_editor": True,
"includes_priority_support": True,
"stripe_price_id": None, # Set from STRIPE_PRO_ANNUAL_PRICE_ID if added
"sort_order": 3,
},
]
def _is_real_stripe_id(val: str | None) -> bool:
"""Return True only if the value looks like a real Stripe price ID."""
if not val:
return False
val = val.strip()
# Reject obvious placeholders
if not val or val.startswith("price_xxxxx") or val in ("price_", "none", "null", ""):
return False
# Real Stripe price IDs start with "price_" and are 20+ chars
return val.startswith("price_") and len(val) > 10
def seed_plans(db_session) -> None:
"""Insert or update seed plans. Safe to call on every startup."""
from app.config import settings
# Map config price IDs to plan slugs (only use real Stripe IDs)
_stripe_ids = {
"per_video": settings.STRIPE_PER_VIDEO_PRICE_ID if _is_real_stripe_id(settings.STRIPE_PER_VIDEO_PRICE_ID) else None,
"pro_monthly": settings.STRIPE_PRO_PRICE_ID if _is_real_stripe_id(settings.STRIPE_PRO_PRICE_ID) else None,
"pro_annual": settings.STRIPE_PRO_ANNUAL_PRICE_ID if _is_real_stripe_id(getattr(settings, "STRIPE_PRO_ANNUAL_PRICE_ID", "")) else None,
}
for seed in SEED_PLANS:
# Work on a copy so we don't mutate the global SEED_PLANS list
plan_data = {**seed}
slug = plan_data["slug"]
existing = db_session.query(SubscriptionPlan).filter_by(slug=slug).first()
# Override stripe_price_id from config if a real ID is available
if slug in _stripe_ids and _stripe_ids[slug]:
plan_data["stripe_price_id"] = _stripe_ids[slug]
if existing:
# Update mutable fields (price, features, stripe id)
for key in ("price_cents", "stripe_price_id", "includes_studio",
"includes_chat_editor", "includes_priority_support",
"video_limit", "description", "name", "sort_order"):
setattr(existing, key, plan_data[key])
else:
db_session.add(SubscriptionPlan(**plan_data))
db_session.commit()
|