# Indie Status Page — SaaS Enhancement Plan > Transform the single-tenant status page into a multi-tenant SaaS product with > user accounts, org-level status pages, feature tiers, and Stripe payment links. --- ## 1. Current State Analysis ### 1.1 Architecture Overview | Aspect | Detail | |---|---| | **Framework** | FastAPI 0.110+ with async SQLAlchemy 2.0 + aiosqlite | | **Database** | SQLite (`data/statuspage.db`), auto-created via `init_db()` — no Alembic migrations yet | | **Deployment** | Port 8765, nginx reverse proxy at `korpo.pro/status/`, systemd service `indie-status-page.service` | | **Templating** | Jinja2 (5 HTML templates) | | **Scheduling** | APScheduler (60s interval uptime checks) | ### 1.2 Database Models (8 total) File: `app/models/models.py` | Model | Table | Key Fields | Relationships | |---|---|---|---| | `Service` | `services` | id, name, slug (unique), description, group_name, position, is_visible | → incidents, → monitors | | `Incident` | `incidents` | id, service_id (FK), title, status, severity, started_at, resolved_at | → service, → updates, → notifications | | `IncidentUpdate` | `incident_updates` | id, incident_id (FK), status, body, created_at | → incident | | `Monitor` | `monitors` | id, service_id (FK), url, method, expected_status, timeout_seconds, interval_seconds, is_active | → service, → results | | `MonitorResult` | `monitor_results` | id, monitor_id (FK), status, response_time_ms, status_code, error_message, checked_at | → monitor | | `Subscriber` | `subscribers` | id, email (unique), is_confirmed, confirm_token | → notifications | | `NotificationLog` | `notification_logs` | id, incident_id (FK), subscriber_id (FK), channel, status | → incident, → subscriber | | `SiteSetting` | `site_settings` | id, key (unique), value | — | ### 1.3 API Routes (25 endpoints) File: `app/api/router.py` — assembles 5 sub-routers under `/api/v1`: | Router | File | Prefix | Endpoints | |---|---|---|---| | **Services** | `app/api/services.py` | `/services` | GET /, POST /, GET /{id}, PATCH /{id}, DELETE /{id} | | **Incidents** | `app/api/incidents.py` | `/incidents` | GET /, POST /, GET /{id}, PATCH /{id}, DELETE /{id}, POST /{id}/updates | | **Monitors** | `app/api/monitors.py` | `/monitors` | GET /, POST /, GET /{id}, PATCH /{id}, DELETE /{id}, POST /{id}/check | | **Subscribers** | `app/api/subscribers.py` | `/subscribers` | GET /, POST /, DELETE /{id}, POST /{id}/confirm | | **Settings** | `app/api/settings.py` | `/settings` | GET /, PATCH / | ### 1.4 Authentication File: `app/dependencies.py` - **Single shared API key**: `X-API-Key` header validated against `settings.admin_api_key` - No user accounts, no sessions, no JWT tokens - All write endpoints require API key; read endpoints are public ### 1.5 Frontend Pages File: `app/main.py` + `app/templates/` | Route | Template | Purpose | |---|---|---| | `GET /` | `status.html` | Public status page (all visible services + recent incidents) | | `GET /incident/{id}` | `incident.html` | Incident detail with update timeline | | `GET /subscribe` | `subscribe.html` | Email subscription form | | `GET /confirm` | `confirm.html` | Subscription confirmation | | `GET /health` | JSON response | Health check for container orchestration | ### 1.6 Background Services | Service | File | Description | |---|---|---| | **Uptime Checker** | `app/services/uptime.py` | HTTP health checks via httpx, stores `MonitorResult` | | **Scheduler** | `app/services/scheduler.py` | APScheduler runs `_run_monitor_checks()` every 60s | | **Notifier** | `app/services/notifier.py` | Email (SMTP) + webhook dispatch for incident updates | ### 1.7 Tests 6 passing tests across 3 files: - `tests/test_health.py` — 1 test (health check) - `tests/test_api_services.py` — 7 tests (full CRUD + 404 + auth) - `tests/test_api_incidents.py` — 5 tests (CRUD + updates + delete) ### 1.8 Key Gaps for SaaS | Gap | Impact | |---|---| | **No user accounts** | Cannot identify who owns what data | | **No multi-tenancy** | All orgs share the same data; no isolation | | **No feature limits** | Any user can create unlimited services/pages | | **No payment integration** | No billing, no subscription management | | **SQLite-only** | Won't handle concurrent multi-tenant writes well | | **No Alembic migrations** | Schema changes require manual DB recreation | | **Single-site config** | `SiteSetting` and `config.py` are global, not per-org | --- ## 2. Multi-Tenancy Design ### 2.1 Strategy: Shared Database, Tenant-ID Columns Use a **shared database with tenant-ID discriminator** approach. Every tenant-scoped table gets an `organization_id` foreign key. All queries filter by the current tenant. This is the simplest path that works with SQLite → PostgreSQL migration. ### 2.2 New Models #### `User` — Individual who can log in ```python # app/models/models.py — NEW class User(Base): __tablename__ = "users" id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid_str) email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) password_hash: Mapped[str] = mapped_column(String(255), nullable=False) display_name: Mapped[str | None] = mapped_column(String(100), nullable=True) is_email_verified: Mapped[bool] = mapped_column(Boolean, default=False) email_verify_token: Mapped[str | None] = mapped_column(String(100), 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 ) memberships: Mapped[list["OrganizationMember"]] = relationship(back_populates="user") ``` #### `Organization` — The tenant; owns status pages ```python class Organization(Base): __tablename__ = "organizations" id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid_str) slug: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True) name: Mapped[str] = mapped_column(String(100), nullable=False) tier: Mapped[str] = mapped_column(String(20), nullable=False, default="free") # "free" | "pro" | "team" stripe_customer_id: Mapped[str | None] = mapped_column(String(100), nullable=True) custom_domain: Mapped[str | None] = mapped_column(String(255), 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 ) members: Mapped[list["OrganizationMember"]] = relationship(back_populates="organization") status_pages: Mapped[list["StatusPage"]] = relationship(back_populates="organization") ``` #### `OrganizationMember` — Joins users to orgs with roles ```python class OrganizationMember(Base): __tablename__ = "organization_members" id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid_str) organization_id: Mapped[str] = mapped_column( String(36), ForeignKey("organizations.id"), nullable=False, index=True ) user_id: Mapped[str] = mapped_column( String(36), ForeignKey("users.id"), nullable=False, index=True ) role: Mapped[str] = mapped_column(String(20), nullable=False, default="member") # "owner" | "admin" | "member" joined_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) organization: Mapped["Organization"] = relationship(back_populates="members") user: Mapped["User"] = relationship(back_populates="memberships") __table_args__ = ( # Prevent duplicate memberships {"sqlite_autoincrement": True}, ) ``` #### `StatusPage` — Replaces the single global status view ```python class StatusPage(Base): __tablename__ = "status_pages" id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid_str) organization_id: Mapped[str] = mapped_column( String(36), ForeignKey("organizations.id"), nullable=False, index=True ) slug: Mapped[str] = mapped_column(String(50), nullable=False, index=True) title: Mapped[str] = mapped_column(String(100), nullable=False) subdomain: Mapped[str | None] = mapped_column(String(100), nullable=True) custom_domain: Mapped[str | None] = mapped_column(String(255), nullable=True) is_public: Mapped[bool] = mapped_column(Boolean, default=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) updated_at: Mapped[datetime] = mapped_column( DateTime, default=datetime.utcnow, onupdate=datetime.utcnow ) organization: Mapped["Organization"] = relationship(back_populates="status_pages") __table_args__ = ( # Unique slug within organization UniqueConstraint("organization_id", "slug", name="uq_status_page_org_slug"), ) ``` ### 2.3 Tenant-ID on Existing Models Every existing model (except `SiteSetting`) gains an `organization_id` FK: ```python # Add to Service: organization_id: Mapped[str] = mapped_column( String(36), ForeignKey("organizations.id"), nullable=False, index=True ) # Add to Subscriber: organization_id: Mapped[str] = mapped_column( String(36), ForeignKey("organizations.id"), nullable=False, index=True ) # SiteSetting gets organization_id (nullable for global settings): organization_id: Mapped[str | None] = mapped_column( String(36), ForeignKey("organizations.id"), nullable=True, index=True ) ``` `Incident`, `IncidentUpdate`, `Monitor`, `MonitorResult`, and `NotificationLog` are implicitly scoped through their parent `Service` → no direct `organization_id` needed if all queries join through `Service`. However, for query performance, add a denormalized `organization_id` on `Incident` and `Monitor`: ```python # Add to Incident: organization_id: Mapped[str] = mapped_column( String(36), ForeignKey("organizations.id"), nullable=False, index=True ) # Add to Monitor: organization_id: Mapped[str] = mapped_column( String(36), ForeignKey("organizations.id"), nullable=False, index=True ) ``` ### 2.4 Auth Flow Replace `verify_api_key` with JWT-based user auth: **New file: `app/api/auth.py`** ```python from datetime import datetime, timedelta from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer from jose import jwt, JWTError from pydantic import BaseModel, EmailStr from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.dependencies import get_db from app.models.models import User, OrganizationMember, Organization router = APIRouter() oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") class LoginRequest(BaseModel): email: EmailStr password: str class RegisterRequest(BaseModel): email: EmailStr password: str display_name: str | None = None org_name: str # Name for auto-created org class TokenResponse(BaseModel): access_token: str token_type: str = "bearer" def create_access_token(user_id: str, exp_hours: int = 72) -> str: payload = { "sub": user_id, "exp": datetime.utcnow() + timedelta(hours=exp_hours), } return jwt.encode(payload, settings.secret_key, algorithm="HS256") async def get_current_user( token: str = Depends(oauth2_scheme), db: AsyncSession = Depends(get_db), ) -> User: """Dependency: extract and validate current user from JWT.""" try: payload = jwt.decode(token, settings.secret_key, algorithms=["HS256"]) user_id = payload.get("sub") except JWTError: raise HTTPException(status_code=401, detail="Invalid token") result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if not user: raise HTTPException(status_code=401, detail="User not found") return user @router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED) async def register(data: RegisterRequest, db: AsyncSession = Depends(get_db)): """Register a new user + auto-create a personal organization.""" # Check if email exists existing = await db.execute(select(User).where(User.email == data.email)) if existing.scalar_one_or_none(): raise HTTPException(status_code=409, detail="Email already registered") from passlib.context import CryptContext pwd_ctx = CryptContext(schemes=["bcrypt"], deprecated="auto") user = User( email=data.email, password_hash=pwd_ctx.hash(data.password), display_name=data.display_name, ) db.add(user) await db.flush() # Auto-create personal org org_slug = data.email.split("@")[0].lower().replace(".", "-") org = Organization(name=data.org_name, slug=org_slug, tier="free") db.add(org) await db.flush() membership = OrganizationMember( organization_id=org.id, user_id=user.id, role="owner" ) db.add(membership) await db.flush() token = create_access_token(user.id) return TokenResponse(access_token=token) @router.post("/login", response_model=TokenResponse) async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)): result = await db.execute(select(User).where(User.email == data.email)) user = result.scalar_one_or_none() if not user: raise HTTPException(status_code=401, detail="Invalid credentials") from passlib.context import CryptContext pwd_ctx = CryptContext(schemes=["bcrypt"], deprecated="auto") if not pwd_ctx.verify(data.password, user.password_hash): raise HTTPException(status_code=401, detail="Invalid credentials") token = create_access_token(user.id) return TokenResponse(access_token=token) ``` ### 2.5 Tenant-Scoped Query Pattern Replace all `select(Service)` queries with tenant-filtered variants: ```python # Before (app/api/services.py line 51): result = await db.execute(select(Service).order_by(Service.position, Service.name)) # After: async def get_current_org( user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> Organization: """Get the user's first org (simplified; add org-switching later).""" result = await db.execute( select(OrganizationMember).where(OrganizationMember.user_id == user.id) ) membership = result.scalars().first() if not membership: raise HTTPException(status_code=403, detail="No organization") org_result = await db.execute( select(Organization).where(Organization.id == membership.organization_id) ) return org_result.scalar_one() # In endpoints: org = Depends(get_current_org) result = await db.execute( select(Service) .where(Service.organization_id == org.id) .order_by(Service.position, Service.name) ) ``` ### 2.6 Multi-Page Public Routes Current `GET /` serves one global page. New design: | Route | Purpose | |---|---| | `GET /` | Landing / marketing page | | `GET /p/{org_slug}` | Org's default status page | | `GET /p/{org_slug}/{page_slug}` | Specific status page | | `GET /p/{org_slug}/incident/{id}` | Incident detail within org context | | Custom domain | Resolves to `StatusPage.custom_domain` → renders that page | ```python # app/main.py — NEW route @app.get("/p/{org_slug}/{page_slug}") async def public_status_page(request: Request, org_slug: str, page_slug: str): from sqlalchemy import select from app.database import async_session_factory from app.models.models import Organization, StatusPage, Service, Incident async with async_session_factory() as db: # Resolve org + page org_result = await db.execute( select(Organization).where(Organization.slug == org_slug) ) org = org_result.scalar_one_or_none() if not org: raise HTTPException(status_code=404) page_result = await db.execute( select(StatusPage).where( StatusPage.organization_id == org.id, StatusPage.slug == page_slug, ) ) page = page_result.scalar_one_or_none() if not page: raise HTTPException(status_code=404) # Get services for this org result = await db.execute( select(Service) .where(Service.organization_id == org.id, Service.is_visible == True) .order_by(Service.position, Service.name) ) services = result.scalars().all() # ... render template with org + page context ``` ### 2.7 Custom Domain Resolution Add middleware to resolve custom domains: ```python # app/middleware.py — NEW from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request from sqlalchemy import select from app.database import async_session_factory from app.models.models import StatusPage, Organization class CustomDomainMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): host = request.headers.get("host", "").split(":")[0] # Skip known app domains if host in ("korpo.pro", "localhost", "127.0.0.1"): return await call_next(request) async with async_session_factory() as db: result = await db.execute( select(StatusPage).where(StatusPage.custom_domain == host) ) page = result.scalar_one_or_none() if page: # Store page info in request state for downstream use request.state.custom_domain_page = page return await call_next(request) ``` --- ## 3. Feature Tiers ### 3.1 Tier Definitions | Feature | Free | Pro ($9/mo) | Team ($29/mo) | |---|---|---|---| | **Status pages** | 1 | 5 | Unlimited | | **Services per page** | 5 | 50 | Unlimited | | **Monitors per service** | 1 | 5 | Unlimited | | **Subscribers** | 25 | 500 | Unlimited | | **Uptime check interval** | 5 min | 1 min | 30 sec | | **Custom domain** | ❌ | ✅ | ✅ | | **Custom branding/CSS** | ❌ | ✅ | ✅ | | **Team members** | 1 | 3 | Unlimited | | **Incident history** | 30 days | 1 year | Unlimited | | **Webhook notifications** | ❌ | ✅ | ✅ | | **API access** | ❌ | ✅ | ✅ | | **Email notifications** | ✅ | ✅ | ✅ | | **SLA badge widget** | ❌ | ✅ | ✅ | | **Status page password protection** | ❌ | ❌ | ✅ | ### 3.2 Enforcement Layer Create a tier-limits module that checks against the org's tier before allowing writes: **New file: `app/services/tier_limits.py`** ```python from fastapi import HTTPException from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession from app.models.models import ( Organization, Service, Monitor, Subscriber, StatusPage, OrganizationMember ) TIER_LIMITS = { "free": { "status_pages": 1, "services_per_page": 5, "monitors_per_service": 1, "subscribers": 25, "members": 1, "check_interval_min": 5, "custom_domain": False, "webhooks": False, "api_access": False, }, "pro": { "status_pages": 5, "services_per_page": 50, "monitors_per_service": 5, "subscribers": 500, "members": 3, "check_interval_min": 1, "custom_domain": True, "webhooks": True, "api_access": True, }, "team": { "status_pages": -1, # unlimited "services_per_page": -1, "monitors_per_service": -1, "subscribers": -1, "members": -1, "check_interval_min": 0, # 30 sec "custom_domain": True, "webhooks": True, "api_access": True, }, } def get_limit(org: Organization, feature: str): """Return the limit for a feature given the org's tier.""" tier = org.tier or "free" return TIER_LIMITS.get(tier, TIER_LIMITS["free"]).get(feature) async def enforce_limit( db: AsyncSession, org: Organization, feature: str, current_count: int ) -> None: """Raise 403 if the current count meets or exceeds the tier limit.""" limit = get_limit(org, feature) if limit == -1: # unlimited return if limit is False: # feature not available raise HTTPException( status_code=403, detail=f"Feature '{feature}' requires a plan upgrade", ) if current_count >= limit: raise HTTPException( status_code=403, detail=f"Tier limit reached for '{feature}' ({limit}). Upgrade your plan.", ) # Concrete helpers: async def check_status_page_limit(db: AsyncSession, org: Organization): count = await db.execute( select(func.count(StatusPage.id)).where(StatusPage.organization_id == org.id) ) await enforce_limit(db, org, "status_pages", count.scalar() or 0) async def check_service_limit(db: AsyncSession, org: Organization, page_id: str): count = await db.execute( select(func.count(Service.id)).where( Service.organization_id == org.id, Service.status_page_id == page_id, ) ) await enforce_limit(db, org, "services_per_page", count.scalar() or 0) async def check_monitor_limit(db: AsyncSession, org: Organization, service_id: str): count = await db.execute( select(func.count(Monitor.id)).where(Monitor.service_id == service_id) ) await enforce_limit(db, org, "monitors_per_service", count.scalar() or 0) async def check_subscriber_limit(db: AsyncSession, org: Organization): count = await db.execute( select(func.count(Subscriber.id)).where(Subscriber.organization_id == org.id) ) await enforce_limit(db, org, "subscribers", count.scalar() or 0) async def check_member_limit(db: AsyncSession, org: Organization): count = await db.execute( select(func.count(OrganizationMember.id)).where( OrganizationMember.organization_id == org.id ) ) await enforce_limit(db, org, "members", count.scalar() or 0) ``` ### 3.3 Usage in Endpoints ```python # app/api/services.py — modify create_service @router.post("/", status_code=status.HTTP_201_CREATED) async def create_service( data: ServiceCreate, org: Organization = Depends(get_current_org), user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): from app.services.tier_limits import check_service_limit await check_service_limit(db, org, data.status_page_id) # ENFORCE LIMIT service = Service( organization_id=org.id, name=data.name, slug=data.slug, # ... ) db.add(service) # ... ``` --- ## 4. Payment Integration — Stripe Checkout Links ### 4.1 Approach: No-Code Payment Links Use **Stripe Checkout Links** (Payment Links) — pre-built hosted checkout pages that require zero server-side Stripe integration. When a user clicks "Upgrade", they're redirected to a Stripe-hosted checkout page. After payment, Stripe redirects back with a `?session_id` parameter. A webhook marks the org as upgraded. ### 4.2 Setup in Stripe Dashboard 1. Create 2 Products: "Pro Plan" ($9/mo) and "Team Plan" ($29/mo) 2. Create recurring prices for each 3. Generate **Payment Links** for each price: - Pro: `https://buy.stripe.com/xxxx_pro_9_monthly` - Team: `https://buy.stripe.com/xxxx_team_29_monthly` 4. Set success URL: `https://korpo.pro/api/v1/billing/success?session_id={CHECKOUT_SESSION_ID}` 5. Set cancel URL: `https://korpo.pro/billing?canceled=1` ### 4.3 Config — Add to `app/config.py` ```python class Settings(BaseSettings): # ... existing fields ... # Stripe stripe_pro_checkout_url: str = "https://buy.stripe.com/xxxx_pro_9_monthly" stripe_team_checkout_url: str = "https://buy.stripe.com/xxxx_team_29_monthly" stripe_webhook_secret: str = "whsec_xxxx" ``` ### 4.4 Checkout Redirect Endpoint **New file: `app/api/billing.py`** ```python from fastapi import APIRouter, Depends, HTTPException, Request from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.dependencies import get_db from app.api.auth import get_current_user from app.models.models import User, Organization, OrganizationMember router = APIRouter() @router.get("/checkout/{tier}") async def create_checkout( tier: str, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Redirect user to Stripe Checkout Link for the selected tier.""" if tier not in ("pro", "team"): raise HTTPException(status_code=400, detail="Invalid tier") # Get user's org result = await db.execute( select(OrganizationMember).where(OrganizationMember.user_id == user.id) ) membership = result.scalars().first() if not membership: raise HTTPException(status_code=403, detail="No organization") # Select checkout URL based on tier checkout_url = ( settings.stripe_pro_checkout_url if tier == "pro" else settings.stripe_team_checkout_url ) # Append metadata so Stripe webhook knows which org to upgrade # Stripe Payment Links support client_reference_id via URL param: from urllib.parse import urlencode params = urlencode({ "client_reference_id": membership.organization_id, "prefilled_email": user.email, }) return {"redirect_url": f"{checkout_url}?{params}"} ``` ### 4.5 Stripe Webhook Handler This is the **only** server-side Stripe code needed. It receives events when payments succeed or subscriptions change. ```python # app/api/billing.py — continued import stripe from fastapi import Header @router.post("/webhook") async def stripe_webhook(request: Request, db: AsyncSession = Depends(get_db)): """Handle Stripe webhook events for subscription changes.""" body = await request.body() sig = request.headers.get("stripe-signature", "") try: event = stripe.Webhook.construct_event( body, sig, settings.stripe_webhook_secret ) except Exception: raise HTTPException(status_code=400, detail="Invalid signature") if event["type"] == "checkout.session.completed": session = event["data"]["object"] org_id = session.get("client_reference_id") customer_id = session.get("customer") if org_id: result = await db.execute( select(Organization).where(Organization.id == org_id) ) org = result.scalar_one_or_none() if org: # Determine tier from the line items amount = session.get("amount_total", 0) if amount >= 2900: # $29+ org.tier = "team" elif amount >= 900: # $9+ org.tier = "pro" org.stripe_customer_id = customer_id await db.flush() elif event["type"] == "customer.subscription.deleted": # Downgrade to free on cancellation customer_id = event["data"]["object"]["customer"] result = await db.execute( select(Organization).where(Organization.stripe_customer_id == customer_id) ) org = result.scalar_one_or_none() if org: org.tier = "free" await db.flush() return {"status": "ok"} ``` ### 4.6 Success Callback ```python @router.get("/success") async def billing_success(session_id: str, db: AsyncSession = Depends(get_db)): """Stripe redirects here after successful checkout.""" # In production, call stripe.checkout.sessions.retrieve(session_id) to verify. # For MVP, webhook already processed it — just show a success page. return {"message": "Subscription activated! Your plan has been upgraded."} ``` ### 4.7 Billing Page Template ```html {% extends "base.html" %} {% block title %}Billing{% endblock %} {% block content %}
$0/mo
✅ Current plan
{% else %}Downgrade{% endif %}$9/mo
✅ Current plan
{% else %}Upgrade to Pro{% endif %}$29/mo
✅ Current plan
{% else %}Upgrade to Team{% endif %}