"""Plan / tier definitions and entitlement model. Per ai-backend-systems-architect's subscription section: the exact names, prices, caps, and multipliers are NOT finalized — this module makes them configuration a plan can be plugged into and changed later WITHOUT a rearchitect. Feature gating is defined here but ENFORCED server-side (see gating.py); the frontend only renders the locked state. Design points the skill requires, implemented here: - Configurable tiers (name/price/caps/multiplier are data, not hardcoded logic). - One hidden, owner-only plan that never appears in any public plan list or API response a normal user can see (`hidden=True` + filtered by list_public_plans). - A per-tier usage multiplier as a configurable value. - "What happens at the cap" is a configurable policy, not a hardcoded behavior. """ from __future__ import annotations from dataclasses import dataclass, field from enum import Enum class Feature(str, Enum): """Every gated capability. A plan grants a subset. Add new ones here; the gate (gating.py) refers to these, so a new feature is one enum + one plan-list edit, never scattered conditionals.""" CHAT = "chat" MEMORY = "memory" WEB_SEARCH = "web_search" OSINT = "osint" AUTOMATION = "automation" AR_BASIC = "ar_basic" AR_MODEL_GEN = "ar_model_gen" # company-provisioned 3D model generation MULTI_MODEL_ORCHESTRATION = "multi_model_orchestration" FLEET_ADMIN = "fleet_admin" # owner-only operational surface class CapPolicy(str, Enum): """What happens when a metered cap is hit — configurable, not hardcoded.""" BLOCK = "block" # refuse until reset window PROMPT_PURCHASE = "prompt_purchase" # offer to buy more usage credits COOLDOWN = "cooldown" # soft-throttle, reset on window @dataclass(frozen=True) class Plan: id: str display_name: str price_cents: int # in the smallest currency unit currency: str features: frozenset[Feature] # Base monthly cap on company-provisioned model calls; None = unmetered. model_call_cap: int | None usage_multiplier: float = 1.0 # higher tiers get N× the base cap cap_policy: CapPolicy = CapPolicy.PROMPT_PURCHASE # Plans that provision real model access just-in-time after a confirmed sale # carry a disclosed provisioning delay (skill: don't pre-buy speculatively). provisioning_delay_hours: int = 0 hidden: bool = False # owner-only; never in public listings # Effective cap = model_call_cap × usage_multiplier (computed, so tuning the # multiplier alone rescales the tier without touching cap numbers). @property def effective_model_cap(self) -> int | None: if self.model_call_cap is None: return None return int(self.model_call_cap * self.usage_multiplier) def grants(self, feature: Feature) -> bool: return feature in self.features # ─── The plan catalog. Names/prices/caps are placeholders to be finalized; the # SHAPE is what matters and is stable. Everything below is data. ─────────────── _ALL_FEATURES = frozenset(f for f in Feature if f != Feature.FLEET_ADMIN) PLANS: dict[str, Plan] = { "free": Plan( id="free", display_name="Free", price_cents=0, currency="USD", features=frozenset({Feature.CHAT, Feature.MEMORY, Feature.WEB_SEARCH, Feature.AR_BASIC}), model_call_cap=50, usage_multiplier=1.0, cap_policy=CapPolicy.BLOCK, ), "plus": Plan( id="plus", display_name="Plus", price_cents=999, currency="USD", features=frozenset({Feature.CHAT, Feature.MEMORY, Feature.WEB_SEARCH, Feature.OSINT, Feature.AUTOMATION, Feature.AR_BASIC, Feature.AR_MODEL_GEN}), model_call_cap=500, usage_multiplier=1.0, cap_policy=CapPolicy.PROMPT_PURCHASE, provisioning_delay_hours=24, ), "pro": Plan( id="pro", display_name="Pro", price_cents=2999, currency="USD", features=_ALL_FEATURES, model_call_cap=500, usage_multiplier=5.0, # 5× the Plus base cap_policy=CapPolicy.PROMPT_PURCHASE, provisioning_delay_hours=24, ), # Hidden, owner-only. Never returned by list_public_plans / the public API. # Unmetered, all features incl. fleet admin. Keeps its own hosting (not the # per-subscriber Space model) — that's enforced elsewhere, this just flags it. "omega_owner": Plan( id="omega_owner", display_name="OMEGA", price_cents=0, currency="USD", features=frozenset(Feature), # everything, including FLEET_ADMIN model_call_cap=None, usage_multiplier=1.0, cap_policy=CapPolicy.BLOCK, hidden=True, ), } DEFAULT_PLAN_ID = "free" # Marketing feature copy shown on the checkout screens. This is deliberately NOT the # Feature enum above: the enum is the enforcement contract (what the backend actually # gates on), while these are the human-readable bullets a subscriber reads before # paying. Keeping them here, next to the prices, makes this module the single source # of truth for everything both checkout screens render. # # Why this exists: the exe (CheckoutScreen.tsx FALLBACK_PLANS) and the APK # (CheckoutScreen.kt DEFAULT_PLANS) each carry an offline copy of the plan list, so a # subscriber still sees plans when /billing/plans is unreachable. Two hand-maintained # copies of a PRICE list in two languages is exactly the drift that bit this project # twice already with the duplicated WebAR bundle. scripts/gen_plan_fallbacks.py # generates both client copies from this module, and --check fails if either has # drifted — so a price can no longer change in one place and not the other. PLAN_MARKETING_FEATURES: dict[str, list[str]] = { "free": ["Chat", "Memory", "Web search", "AR basic"], "plus": ["Everything in Free", "OSINT", "Automation", "AR model-gen"], "pro": ["Everything in Plus", "5× usage", "All AR features"], } # The operator's own identity in the current single-tenant deployment. This account # is on the hidden owner plan by default (full, unmetered) without a subscription row # — so the operator is never gated off their own routes, while every real subscriber # resolves through the normal plan/subscription path. Kept here (not in gating.py) so # both gating and subscription can reference it without a circular import. OWNER_USER_ID = "owner" OWNER_PLAN_ID = "omega_owner" def get_plan(plan_id: str) -> Plan | None: return PLANS.get(plan_id) def list_public_plans() -> list[Plan]: """Everything a normal user may see — hidden plans are filtered out here, at the single source, so no route can accidentally leak the owner plan.""" return [p for p in PLANS.values() if not p.hidden] def is_hidden(plan_id: str) -> bool: p = PLANS.get(plan_id) return bool(p and p.hidden)