Spaces:
Runtime error
Runtime error
File size: 10,893 Bytes
070c931 00a4363 070c931 00a4363 3f79c45 070c931 00a4363 070c931 00a4363 d149013 00a4363 79ca2b4 00a4363 070c931 66de9c8 00a4363 070c931 0649f1b 66de9c8 070c931 66de9c8 070c931 00a4363 66de9c8 00a4363 070c931 00a4363 66de9c8 d149013 66de9c8 070c931 d149013 0649f1b 00a4363 66de9c8 00a4363 66de9c8 0649f1b 00a4363 070c931 00a4363 79ca2b4 66de9c8 00a4363 070c931 66de9c8 0649f1b 00a4363 66de9c8 070c931 66de9c8 0649f1b 00a4363 070c931 00a4363 79ca2b4 66de9c8 00a4363 070c931 66de9c8 0649f1b 00a4363 66de9c8 070c931 66de9c8 0649f1b 00a4363 070c931 00a4363 070c931 66de9c8 0649f1b 66de9c8 070c931 79ca2b4 d149013 79ca2b4 d149013 79ca2b4 00a4363 d149013 79ca2b4 00a4363 811ee7a 00a4363 3f79c45 66de9c8 070c931 3f79c45 070c931 3f79c45 070c931 3f79c45 070c931 3f79c45 070c931 3f79c45 | 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 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 | """Plans live in the `plans` table (edit a row to change limits or prices; it takes effect within a minute).
DEFAULT_PLANS below seeds that table and is used whenever it is empty or unreachable.
While BILLING_ENABLED is false every workspace gets Studio limits for free."""
import logging
import threading
import time
from collections.abc import Iterator, Mapping
from dataclasses import asdict, dataclass, field
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from app.config import settings
from app.models import PlanRecord, Subscription, Workspace
log = logging.getLogger(__name__)
ANNUAL_DISCOUNT = 0.25 # annual billing: 25% off the monthly price
@dataclass(frozen=True)
class Plan:
id: str
name: str
tagline: str
price_monthly_usd: float
sources: int
indexed_posts: int
audio_minutes: int
videos: int # blog2video videos per month, or in total when videos_monthly is False
launch_kits: int # a month (or in total on a lifetime plan); -1 = unlimited
voice_cloning: bool
features: tuple[str, ...]
# Per-workspace shares of what blog2video only limits account-wide (keys in VIDEO_LIMITS; 0 = none).
video_limits: dict = field(default_factory=dict)
videos_monthly: bool = True
reports: int = 0 # reports a month; -1 = unlimited
infographics: int = 0 # infographics a month (in total on a lifetime plan); -1 = unlimited
audio_overviews: int = -1 # audio overviews allowed (in total on a lifetime plan), whatever their length; -1 = no cap
@property
def lifetime(self) -> bool:
"""The allowances for videos, audio overviews, Launch Kits and infographics are totals that never reset (Free)."""
return not self.videos_monthly
# metric -> period it counts over (app/services/video_limits.py)
VIDEO_LIMITS = {
"ai_edits": "month", # scene regenerate/add, AI images, stock clips
"templates": "all", # custom templates: blog2video's slots are lifetime, never given back
"template_ai_daily": "day", # template theme extraction from a prompt or document, code generation
}
FREE_VIDEO_LIMITS = {"ai_edits": 20, "templates": 0, "template_ai_daily": 0}
WRITER_VIDEO_LIMITS = {"ai_edits": 300, "templates": 2, "template_ai_daily": 3}
STUDIO_VIDEO_LIMITS = {"ai_edits": 1000, "templates": 5, "template_ai_daily": 5}
DEFAULT_PLANS: dict[str, Plan] = {
"free": Plan(
id="free",
name="Free",
tagline="For trying Notestack on your archive",
price_monthly_usd=0,
sources=-1,
indexed_posts=5,
audio_minutes=-1,
audio_overviews=1,
videos=1,
launch_kits=2,
reports=2,
infographics=2,
voice_cloning=False,
features=(
"Up to 5 posts indexed, from any source",
"Grounded research chat with citations",
"1 audio overview, any length",
"1 video",
"2 Launch Kits",
"2 infographics",
"2 reports a month",
),
video_limits=FREE_VIDEO_LIMITS,
videos_monthly=False,
),
"writer": Plan(
id="writer",
name="Writer",
tagline="For writers publishing every week",
price_monthly_usd=24.99,
sources=-1,
indexed_posts=100,
audio_minutes=60,
videos=10,
launch_kits=20,
reports=30,
infographics=30,
voice_cloning=True,
features=(
"Up to 100 posts indexed, from any source",
"10x more audio overviews than Free (60 min a month)",
"10 videos a month",
"20 Launch Kits a month",
"30 reports a month",
"30 infographics a month",
"Voice cloning with consent",
),
video_limits=WRITER_VIDEO_LIMITS,
),
"studio": Plan(
id="studio",
name="Studio",
tagline="For publications and power users",
price_monthly_usd=48.99,
sources=-1,
indexed_posts=250,
audio_minutes=240,
videos=20,
launch_kits=50,
reports=-1,
infographics=-1,
voice_cloning=True,
features=(
"Up to 250 posts indexed, from any source",
"40x more audio overviews than Free (240 min a month)",
"20 videos a month",
"50 Launch Kits a month",
"Unlimited reports",
"Unlimited infographics",
"Launchpad calendar and resurfacing",
"Priority rendering",
),
video_limits=STUDIO_VIDEO_LIMITS,
),
}
CACHE_SECONDS = 60
def _plan_from_row(row: PlanRecord) -> Plan:
return Plan(id=row.id, name=row.name, tagline=row.tagline or "",
price_monthly_usd=float(row.price_monthly_usd or 0), sources=row.sources,
indexed_posts=row.indexed_posts, audio_minutes=row.audio_minutes,
videos=row.videos, launch_kits=row.launch_kits, reports=row.reports, infographics=row.infographics, audio_overviews=row.audio_overviews,
voice_cloning=bool(row.voice_cloning),
features=tuple(row.features or ()),
videos_monthly=bool(row.videos_monthly),
video_limits={**{k: 0 for k in VIDEO_LIMITS}, **(row.video_limits or {})})
def _read_table() -> dict[str, Plan] | None:
from app.db import SessionLocal
try:
with SessionLocal() as db:
rows = db.scalars(select(PlanRecord).order_by(PlanRecord.sort_order, PlanRecord.price_monthly_usd)).all()
plans = {r.id: _plan_from_row(r) for r in rows}
except SQLAlchemyError as e:
log.warning("plans table unreadable, using built-in plans: %s", type(e).__name__)
return None
if plans and "free" not in plans:
log.error("plans table has no 'free' row, using built-in plans")
return None
return plans or None
class PlanCatalog(Mapping[str, Plan]):
"""PLANS: the plans table, cheapest first, cached for CACHE_SECONDS."""
def __init__(self) -> None:
self._plans: dict[str, Plan] | None = None
self._loaded_at = 0.0
self._lock = threading.Lock()
def _current(self) -> dict[str, Plan]:
now = time.monotonic()
if self._plans is None or now - self._loaded_at >= CACHE_SECONDS:
with self._lock:
if self._plans is None or now - self._loaded_at >= CACHE_SECONDS:
self._plans = _read_table() or DEFAULT_PLANS
self._loaded_at = now
return self._plans
def reload(self) -> None:
"""Drop the cache so the next read sees table edits immediately."""
self._plans = None
def order(self) -> list[str]:
return list(self._current())
def __getitem__(self, plan_id: str) -> Plan:
return self._current()[plan_id]
def __iter__(self) -> Iterator[str]:
return iter(self._current())
def __len__(self) -> int:
return len(self._current())
PLANS = PlanCatalog()
def to_99(amount: float) -> float:
"""Nearest price ending in .99 (18.74 -> 18.99, 36.74 -> 36.99)."""
return 0.0 if amount <= 0 else round(max(0.99, round(amount + 0.01) - 0.01), 2)
def annual_prices(plan: Plan) -> tuple[float, float]:
"""(effective monthly price, total billed per year) on annual billing, shown as .99 prices."""
per_month = to_99(plan.price_monthly_usd * (1 - ANNUAL_DISCOUNT))
return per_month, round(per_month * 12, 2)
def annual_savings_pct(plan: Plan) -> int:
if not plan.price_monthly_usd:
return 0
per_month, _ = annual_prices(plan)
return round((1 - per_month / plan.price_monthly_usd) * 100)
def plan_dict(plan: Plan) -> dict:
data = asdict(plan)
data["features"] = list(plan.features)
data["price_annual_monthly_usd"], data["price_annual_usd"] = annual_prices(plan)
data["annual_discount"] = ANNUAL_DISCOUNT
data["annual_savings_pct"] = annual_savings_pct(plan)
return data
def effective_plan(db: Session, workspace: Workspace) -> Plan:
if not settings.billing_enabled:
return PLANS["studio"]
sub = db.query(Subscription).filter_by(workspace_id=workspace.id).one_or_none()
# past_due keeps the plan while Stripe retries the card (grace period); unpaid or canceled drop to Free.
if not sub or sub.status not in {"active", "trialing", "past_due"}:
return PLANS["free"]
return PLANS.get(sub.plan, PLANS["free"])
def post_room(db: Session, workspace_id, plan: Plan, source_id=None) -> int:
"""How many more posts the plan lets this workspace index: its limit across every source, link and upload, less the
posts indexed already. With `source_id`, that source's own posts are left out of the count, and a source never loses
posts it already has indexed (a plan that went down keeps what is stored; it only stops new ones)."""
from sqlalchemy import func
from app.models import Document
base = select(func.count()).select_from(Document).where(Document.workspace_id == workspace_id, Document.path.is_not(None))
total = db.scalar(base) or 0
if source_id is None:
return max(plan.indexed_posts - total, 0)
own = db.scalar(base.where(Document.source_id == source_id)) or 0
return max(plan.indexed_posts - (total - own), own)
def plan_order() -> list[str]:
return PLANS.order()
def next_plan(plan: Plan) -> Plan | None:
order = plan_order()
i = order.index(plan.id)
return PLANS[order[i + 1]] if i + 1 < len(order) else None
def upgrade_for(plan: Plan, kind: str | None = None) -> str | None:
"""Cheapest plan above this one that raises the limit named by kind (or simply the next tier)."""
order = plan_order()
for pid in order[order.index(plan.id) + 1:]:
candidate = PLANS[pid]
if kind is None:
return pid
current, better = plan_value(plan, kind), plan_value(candidate, kind)
if isinstance(better, bool):
if better and not current:
return pid
elif isinstance(better, int | float) and (better < 0 or better > current):
return pid
return None
def plan_value(plan: Plan, kind: str):
"""A plan's limit or flag by name; "video_limits.<metric>" reads the per-metric video limits."""
if kind.startswith("video_limits."):
return plan.video_limits.get(kind.removeprefix("video_limits."), 0)
return getattr(plan, kind, 0)
def plan_limit_error(plan: Plan, kind: str, message: str) -> HTTPException:
"""402 the frontend turns into the out of fuel popup."""
return HTTPException(402, {"code": "plan_limit", "message": message, "kind": kind,
"plan": plan.id, "upgrade_to": upgrade_for(plan, kind)})
|