feat(subscriptions): add SaaS subscription tiers, pricing page, and enforced upload quotas

- Add Free / Starter / Professional / Business tiers with lifetime, daily, and monthly
  file limits (app/utils/subscription.py)
- Add subscription_tier column to UserProfile model + migration 014
- Enforce quotas at upload time (HTTP 402 on violation) in /api/ui-upload
- New REST API: GET /api/subscriptions/tiers, /my, /platform (admin)
- New pages: /pricing (marketing, public) and /subscription (per-user status)
- Enhanced dashboard: SaaS stats (files today/month, OCR count, active users)
  in multi-user mode; original single-user layout preserved
- Admin users page: show Plan badge, allow tier editing via dropdown
- Navigation: add Pricing link + subscription icon in user header
- Tests: 23 unit tests for subscription tier logic
- Docs: docs/SubscriptionTiers.md

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-06 15:53:12 +00:00
parent 70dd35dec4
commit 179f6125e8
18 changed files with 2014 additions and 18 deletions
+2
View File
@@ -23,6 +23,7 @@ from app.api.saved_searches import router as saved_searches_router
from app.api.search import router as search_router
from app.api.settings import router as settings_router
from app.api.similarity import router as similarity_router
from app.api.subscriptions import router as subscriptions_router
from app.api.url_upload import router as url_upload_router
# Import all the individual routers
@@ -56,3 +57,4 @@ router.include_router(similarity_router)
router.include_router(duplicates_router)
router.include_router(webhooks_router)
router.include_router(database_router)
router.include_router(subscriptions_router)
+18
View File
@@ -51,6 +51,10 @@ class UserProfileUpsert(BaseModel):
)
notes: str | None = Field(default=None, max_length=4096, description="Admin notes about this user")
is_blocked: bool = Field(default=False, description="Block this user from uploading")
subscription_tier: str | None = Field(
default="free",
description="Subscription tier: free | starter | professional | business",
)
class UserProfileResponse(BaseModel):
@@ -62,6 +66,7 @@ class UserProfileResponse(BaseModel):
daily_upload_limit: int | None
notes: str | None
is_blocked: bool
subscription_tier: str | None
created_at: str | None
updated_at: str | None
@@ -76,6 +81,7 @@ class UserSummary(BaseModel):
daily_upload_limit: int | None
notes: str | None
is_blocked: bool
subscription_tier: str | None
profile_id: int | None
document_count: int
last_upload: str | None
@@ -99,6 +105,7 @@ def _profile_to_dict(profile: UserProfile) -> dict[str, Any]:
"daily_upload_limit": profile.daily_upload_limit,
"notes": profile.notes,
"is_blocked": profile.is_blocked,
"subscription_tier": profile.subscription_tier or "free",
"created_at": profile.created_at.isoformat() if profile.created_at else None,
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
}
@@ -164,6 +171,7 @@ def list_users(
"daily_upload_limit": profile.daily_upload_limit if profile else None,
"notes": profile.notes if profile else None,
"is_blocked": profile.is_blocked if profile else False,
"subscription_tier": (profile.subscription_tier or "free") if profile else "free",
"profile_id": profile.id if profile else None,
"document_count": doc_row.doc_count if doc_row else 0,
"last_upload": doc_row.last_upload.isoformat() if (doc_row and doc_row.last_upload) else None,
@@ -199,6 +207,7 @@ def get_user(user_id: str, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
"daily_upload_limit": profile.daily_upload_limit if profile else None,
"notes": profile.notes if profile else None,
"is_blocked": profile.is_blocked if profile else False,
"subscription_tier": (profile.subscription_tier or "free") if profile else "free",
"profile_id": profile.id if profile else None,
"document_count": doc_count,
"last_upload": last_upload,
@@ -226,6 +235,15 @@ def upsert_user_profile(
profile.daily_upload_limit = body.daily_upload_limit
profile.notes = body.notes
profile.is_blocked = body.is_blocked
if body.subscription_tier is not None:
from app.utils.subscription import TIERS
if body.subscription_tier not in TIERS:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid subscription_tier '{body.subscription_tier}'. Valid values: {list(TIERS.keys())}",
)
profile.subscription_tier = body.subscription_tier
try:
db.commit()
+17 -1
View File
@@ -11,7 +11,7 @@ import zipfile
from datetime import datetime, timezone
from typing import Annotated, List, Optional
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile, status
from fastapi.responses import StreamingResponse
from sqlalchemy import asc, desc
from sqlalchemy.orm import Session
@@ -1295,6 +1295,22 @@ async def ui_upload(request: Request, db: DbSession, file: UploadFile = File(...
# Determine the owner_id for multi-user document isolation
upload_owner_id = get_current_owner_id(request) if settings.multi_user_enabled else None
# Enforce subscription tier upload quotas (multi-user mode only)
if settings.multi_user_enabled and upload_owner_id:
from app.utils.subscription import QuotaExceeded, check_upload_allowed, get_user_tier_id
tier_id = get_user_tier_id(db, upload_owner_id)
try:
check_upload_allowed(db, upload_owner_id, tier_id)
except QuotaExceeded as qe:
# Clean up the already-saved file before rejecting
if os.path.exists(target_path):
os.remove(target_path)
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail=str(qe),
)
# Check if it's a PDF by extension or MIME type
is_pdf = file_ext == ".pdf" or mime_type == "application/pdf"
+153
View File
@@ -0,0 +1,153 @@
"""API endpoints for subscription tiers and usage statistics.
Public endpoints:
GET /api/subscriptions/tiers — list all available plans
GET /api/subscriptions/my — current user's plan + usage (auth required)
GET /api/subscriptions/platform — platform-wide stats (admin only)
"""
import logging
from datetime import datetime, timezone
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.database import get_db
from app.utils.subscription import (
TIER_ORDER,
TIERS,
get_all_tiers,
get_tier,
get_user_tier_id,
get_user_usage,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/subscriptions", tags=["subscriptions"])
DbSession = Annotated[Session, Depends(get_db)]
# ---------------------------------------------------------------------------
# Auth helpers
# ---------------------------------------------------------------------------
def _get_current_user(request: Request) -> dict | None:
return request.session.get("user")
def _require_admin(request: Request) -> dict:
user = request.session.get("user")
if not user or not user.get("is_admin"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return user
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("/tiers", summary="List all subscription tiers")
def list_tiers() -> dict[str, Any]:
"""Return the full list of subscription plans in display order."""
return {
"tiers": get_all_tiers(),
"order": TIER_ORDER,
"default": "free",
}
@router.get("/my", summary="Get current user's subscription and usage")
def my_subscription(request: Request, db: DbSession) -> dict[str, Any]:
"""Return the authenticated user's subscription tier and current usage counts."""
from app.config import settings
user = _get_current_user(request)
if not settings.multi_user_enabled:
# In single-user mode there is no concept of a subscription plan
return {
"multi_user_mode": False,
"tier": TIERS["business"], # unrestricted
"usage": None,
}
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
owner_id: str = user.get("username") or user.get("email") or user.get("sub") or ""
tier_id = get_user_tier_id(db, owner_id)
tier = get_tier(tier_id)
usage = get_user_usage(db, owner_id)
return {
"multi_user_mode": True,
"owner_id": owner_id,
"tier": tier,
"usage": usage,
}
@router.get("/platform", summary="Platform-wide usage statistics (admin only)")
def platform_stats(request: Request, db: DbSession) -> dict[str, Any]:
"""Return aggregate statistics across all users and tiers (admin only)."""
_require_admin(request)
from app.models import FileRecord, UserProfile
today = datetime.now(timezone.utc).date()
# Total files
total_files: int = db.query(func.count(FileRecord.id)).scalar() or 0
# Files today
files_today: int = (
db.query(func.count(FileRecord.id)).filter(func.date(FileRecord.created_at) == today).scalar() or 0
)
# Files this month
files_this_month: int = (
db.query(func.count(FileRecord.id))
.filter(func.strftime("%Y-%m", FileRecord.created_at) == today.strftime("%Y-%m"))
.scalar()
or 0
)
# Files with OCR text (proxy for pages OCRed — approximation)
files_with_ocr: int = db.query(func.count(FileRecord.id)).filter(FileRecord.ocr_text.isnot(None)).scalar() or 0
# Unique active users (ever uploaded)
unique_users: int = (
db.query(func.count(func.distinct(FileRecord.owner_id))).filter(FileRecord.owner_id.isnot(None)).scalar() or 0
)
# Users per subscription tier
profiles = (
db.query(UserProfile.subscription_tier, func.count(UserProfile.id))
.group_by(UserProfile.subscription_tier)
.all()
)
tier_distribution: dict[str, int] = {row[0] or "free": row[1] for row in profiles}
# Fill in zeros for tiers with no users
for tid in TIER_ORDER:
tier_distribution.setdefault(tid, 0)
return {
"files": {
"total": total_files,
"today": files_today,
"this_month": files_this_month,
"with_ocr": files_with_ocr,
},
"users": {
"unique_uploaders": unique_users,
"tier_distribution": tier_distribution,
},
"generated_at": datetime.now(timezone.utc).isoformat(),
}
+4
View File
@@ -200,5 +200,9 @@ class UserProfile(Base):
# When True the user is prevented from uploading new documents
is_blocked = Column(Boolean, default=False, nullable=False)
# Subscription tier: "free" | "starter" | "professional" | "business"
# NULL is treated as "free" by the subscription utility.
subscription_tier = Column(String(50), nullable=True, default="free")
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
+303
View File
@@ -0,0 +1,303 @@
"""
Subscription tier definitions and enforcement utilities for DocuElevate SaaS.
Four tiers:
- free $0/mo — 25 lifetime files, 1 destination, 50 OCR pages/mo
- starter $9/mo — 10/day, 100/mo, 3 destinations, 500 OCR pages/mo
- professional $29/mo — 50/day, 500/mo, 10 destinations, 2 500 OCR pages/mo
- business $79/mo — unlimited, unlimited destinations, unlimited OCR
Limits use 0 to represent "unlimited".
"""
from __future__ import annotations
import logging
from datetime import date, datetime, timezone
from typing import Any
from sqlalchemy import func
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Tier catalogue
# ---------------------------------------------------------------------------
TIERS: dict[str, dict[str, Any]] = {
"free": {
"id": "free",
"name": "Free",
"tagline": "Explore DocuElevate at no cost",
"price_monthly": 0,
"price_yearly": 0,
"highlight": False,
# Hard caps — 0 = unlimited
"lifetime_file_limit": 25, # total files ever processed
"daily_upload_limit": 0, # no per-day cap (capped by lifetime)
"monthly_upload_limit": 0, # no per-month cap (capped by lifetime)
"max_storage_destinations": 1,
"max_ocr_pages_monthly": 50,
"max_file_size_mb": 10,
"api_access": False,
# Marketing feature list (shown on pricing page)
"features": [
"25 documents lifetime total",
"1 storage destination",
"50 OCR pages / month",
"10 MB max file size",
"Basic AI metadata extraction",
"Community support",
],
"cta": "Get started free",
"badge": None,
},
"starter": {
"id": "starter",
"name": "Starter",
"tagline": "Perfect for individuals & small teams",
"price_monthly": 9,
"price_yearly": 90,
"highlight": False,
"lifetime_file_limit": 0,
"daily_upload_limit": 10,
"monthly_upload_limit": 100,
"max_storage_destinations": 3,
"max_ocr_pages_monthly": 500,
"max_file_size_mb": 50,
"api_access": True,
"features": [
"10 documents / day",
"100 documents / month",
"3 storage destinations",
"500 OCR pages / month",
"50 MB max file size",
"Full AI metadata extraction",
"Email ingestion",
"API access",
"Email support",
],
"cta": "Start with Starter",
"badge": None,
},
"professional": {
"id": "professional",
"name": "Professional",
"tagline": "For growing teams that need more power",
"price_monthly": 29,
"price_yearly": 290,
"highlight": True, # shown as "Most popular"
"lifetime_file_limit": 0,
"daily_upload_limit": 50,
"monthly_upload_limit": 500,
"max_storage_destinations": 10,
"max_ocr_pages_monthly": 2500,
"max_file_size_mb": 200,
"api_access": True,
"features": [
"50 documents / day",
"500 documents / month",
"10 storage destinations",
"2 500 OCR pages / month",
"200 MB max file size",
"Advanced AI workflows",
"Email & URL ingestion",
"Webhooks",
"Priority email support",
],
"cta": "Go Professional",
"badge": "Most Popular",
},
"business": {
"id": "business",
"name": "Business",
"tagline": "Unlimited processing for organisations",
"price_monthly": 79,
"price_yearly": 790,
"highlight": False,
"lifetime_file_limit": 0,
"daily_upload_limit": 0,
"monthly_upload_limit": 0,
"max_storage_destinations": 0,
"max_ocr_pages_monthly": 0,
"max_file_size_mb": 0,
"api_access": True,
"features": [
"Unlimited documents",
"Unlimited storage destinations",
"Unlimited OCR pages",
"Unlimited file size",
"All AI processing steps",
"All ingestion methods",
"Webhooks & full API access",
"Custom integrations",
"Dedicated support",
],
"cta": "Contact Sales",
"badge": "Best Value",
},
}
# Display order for the pricing page
TIER_ORDER = ["free", "starter", "professional", "business"]
# Default tier assigned to new users
DEFAULT_TIER = "free"
# ---------------------------------------------------------------------------
# Getters
# ---------------------------------------------------------------------------
def get_tier(tier_id: str) -> dict[str, Any]:
"""Return tier config dict; falls back to *free* for unknown ids."""
return TIERS.get(tier_id, TIERS["free"])
def get_all_tiers() -> list[dict[str, Any]]:
"""Return tiers in display order."""
return [TIERS[tid] for tid in TIER_ORDER]
# ---------------------------------------------------------------------------
# Usage queries
# ---------------------------------------------------------------------------
def _today_utc() -> date:
return datetime.now(timezone.utc).date()
def get_lifetime_file_count(db: Session, owner_id: str) -> int:
"""Total files ever processed by this user (not counting duplicates)."""
from app.models import FileRecord
return (
db.query(func.count(FileRecord.id))
.filter(FileRecord.owner_id == owner_id, FileRecord.is_duplicate.is_(False))
.scalar()
or 0
)
def get_today_file_count(db: Session, owner_id: str) -> int:
"""Files processed by this user today (UTC, not counting duplicates)."""
from app.models import FileRecord
today = _today_utc()
return (
db.query(func.count(FileRecord.id))
.filter(
FileRecord.owner_id == owner_id,
FileRecord.is_duplicate.is_(False),
func.date(FileRecord.created_at) == today,
)
.scalar()
or 0
)
def get_month_file_count(db: Session, owner_id: str) -> int:
"""Files processed by this user this calendar month (UTC, not counting duplicates)."""
from app.models import FileRecord
today = _today_utc()
return (
db.query(func.count(FileRecord.id))
.filter(
FileRecord.owner_id == owner_id,
FileRecord.is_duplicate.is_(False),
func.strftime("%Y-%m", FileRecord.created_at) == today.strftime("%Y-%m"),
)
.scalar()
or 0
)
# ---------------------------------------------------------------------------
# Limit enforcement
# ---------------------------------------------------------------------------
class QuotaExceeded(Exception):
"""Raised when a user has hit a subscription limit."""
def __init__(self, message: str, limit_type: str, limit_value: int, current_value: int) -> None:
super().__init__(message)
self.limit_type = limit_type
self.limit_value = limit_value
self.current_value = current_value
def check_upload_allowed(db: Session, owner_id: str | None, tier_id: str | None) -> None:
"""Raise :class:`QuotaExceeded` if this user is not allowed to upload another file.
When *owner_id* or *tier_id* is ``None`` (e.g. single-user mode) the check
is skipped entirely.
"""
if owner_id is None or tier_id is None:
return
tier = get_tier(tier_id)
# 1. Lifetime file cap (free tier)
lifetime_limit = tier["lifetime_file_limit"]
if lifetime_limit > 0:
count = get_lifetime_file_count(db, owner_id)
if count >= lifetime_limit:
raise QuotaExceeded(
f"Lifetime file limit of {lifetime_limit} reached for the {tier['name']} plan. "
"Please upgrade to continue processing documents.",
limit_type="lifetime",
limit_value=lifetime_limit,
current_value=count,
)
# 2. Daily cap
daily_limit = tier["daily_upload_limit"]
if daily_limit > 0:
count = get_today_file_count(db, owner_id)
if count >= daily_limit:
raise QuotaExceeded(
f"Daily file limit of {daily_limit} reached for the {tier['name']} plan. "
"Please try again tomorrow or upgrade your plan.",
limit_type="daily",
limit_value=daily_limit,
current_value=count,
)
# 3. Monthly cap
monthly_limit = tier["monthly_upload_limit"]
if monthly_limit > 0:
count = get_month_file_count(db, owner_id)
if count >= monthly_limit:
raise QuotaExceeded(
f"Monthly file limit of {monthly_limit} reached for the {tier['name']} plan. "
"Please upgrade your plan for more documents this month.",
limit_type="monthly",
limit_value=monthly_limit,
current_value=count,
)
def get_user_tier_id(db: Session, owner_id: str | None) -> str:
"""Return the subscription tier id for *owner_id*, defaulting to 'free'."""
if owner_id is None:
return DEFAULT_TIER
from app.models import UserProfile
profile = db.query(UserProfile).filter(UserProfile.user_id == owner_id).first()
if profile and profile.subscription_tier:
return profile.subscription_tier
return DEFAULT_TIER
def get_user_usage(db: Session, owner_id: str) -> dict[str, int]:
"""Return a dict with lifetime / daily / monthly file counts for *owner_id*."""
return {
"lifetime": get_lifetime_file_count(db, owner_id),
"today": get_today_file_count(db, owner_id),
"month": get_month_file_count(db, owner_id),
}
+2
View File
@@ -18,6 +18,7 @@ from app.views.queue import router as queue_router
from app.views.search import router as search_router
from app.views.settings import router as settings_router
from app.views.status import router as status_router
from app.views.subscriptions import router as subscriptions_router
from app.views.wizard import router as wizard_router
# Create a main router that includes all the view routers
@@ -35,3 +36,4 @@ router.include_router(settings_router)
router.include_router(filemanager_router)
router.include_router(search_router)
router.include_router(queue_router)
router.include_router(subscriptions_router) # Pricing + subscription pages
+63 -11
View File
@@ -2,11 +2,12 @@
General routes for the application homepage and basic pages.
"""
from datetime import date
from datetime import date, datetime, timezone
from pathlib import Path
from fastapi import Depends, HTTPException, Request
from fastapi.responses import FileResponse, RedirectResponse
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.utils.config_validator import get_provider_status, validate_storage_configs
@@ -54,25 +55,76 @@ async def serve_index(request: Request, db: Session = Depends(get_db)):
and provider in ["dropbox", "nextcloud", "sftp", "s3", "ftp", "webdav", "google_drive", "onedrive"]
)
# Query the actual file count from the database
processed_files = 0
from app.models import FileRecord
today = datetime.now(timezone.utc).date()
# Global file counts (or per-user in multi-user mode)
from app.config import settings
user = request.session.get("user") or {}
is_admin = user.get("is_admin", False)
try:
# Import the model here to avoid circular imports
from app.models import FileRecord
total_files: int = db.query(func.count(FileRecord.id)).scalar() or 0
processed_files = db.query(FileRecord.id).count()
files_today: int = (
db.query(func.count(FileRecord.id)).filter(func.date(FileRecord.created_at) == today).scalar() or 0
)
files_month: int = (
db.query(func.count(FileRecord.id))
.filter(func.strftime("%Y-%m", FileRecord.created_at) == today.strftime("%Y-%m"))
.scalar()
or 0
)
files_with_ocr: int = db.query(func.count(FileRecord.id)).filter(FileRecord.ocr_text.isnot(None)).scalar() or 0
unique_users: int = (
db.query(func.count(func.distinct(FileRecord.owner_id))).filter(FileRecord.owner_id.isnot(None)).scalar()
or 0
)
except Exception as e:
# Log error but continue (don't break the page if DB query fails)
logger.error(f"Error counting files: {str(e)}")
logger.error(f"Error computing dashboard stats: {e}")
total_files = files_today = files_month = files_with_ocr = unique_users = 0
# Per-user usage for the subscription widget (multi-user only)
user_usage = None
user_tier = None
if settings.multi_user_enabled:
owner_id: str = user.get("username") or user.get("email") or user.get("sub") or ""
if owner_id:
try:
from app.utils.subscription import get_tier, get_user_tier_id, get_user_usage
tier_id = get_user_tier_id(db, owner_id)
user_tier = get_tier(tier_id)
user_usage = get_user_usage(db, owner_id)
except Exception as e:
logger.error(f"Error fetching subscription info: {e}")
# Create stats object to pass to the template
stats = {
"processed_files": processed_files,
"processed_files": total_files,
"files_today": files_today,
"files_month": files_month,
"files_with_ocr": files_with_ocr,
"unique_users": unique_users,
"active_integrations": configured_providers,
"storage_targets": configured_storage_targets,
}
return templates.TemplateResponse("index.html", {"request": request, "stats": stats})
return templates.TemplateResponse(
"index.html",
{
"request": request,
"stats": stats,
"user_usage": user_usage,
"user_tier": user_tier,
"multi_user_enabled": settings.multi_user_enabled,
"is_admin": is_admin,
},
)
@router.get("/about", include_in_schema=False)
+64
View File
@@ -0,0 +1,64 @@
"""View routes for subscription-related pages.
Routes:
GET /pricing — public marketing pricing page
GET /subscription — authenticated user's current plan & usage
"""
import logging
from fastapi import Depends, Request
from sqlalchemy.orm import Session
from app.utils.subscription import TIER_ORDER, get_all_tiers, get_tier, get_user_tier_id, get_user_usage
from app.views.base import APIRouter, get_db, require_login, templates
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/pricing", include_in_schema=False)
async def pricing_page(request: Request):
"""Public-facing pricing and plans page."""
tiers = get_all_tiers()
return templates.TemplateResponse(
"pricing.html",
{
"request": request,
"tiers": tiers,
"tier_order": TIER_ORDER,
},
)
@router.get("/subscription", include_in_schema=False)
@require_login
async def my_subscription_page(request: Request, db: Session = Depends(get_db)):
"""Authenticated user's subscription status and usage page."""
from app.config import settings
user = request.session.get("user") or {}
owner_id: str = user.get("username") or user.get("email") or user.get("sub") or ""
if settings.multi_user_enabled and owner_id:
tier_id = get_user_tier_id(db, owner_id)
usage = get_user_usage(db, owner_id)
else:
tier_id = "business"
usage = None
tier = get_tier(tier_id)
all_tiers = get_all_tiers()
return templates.TemplateResponse(
"subscription.html",
{
"request": request,
"tier": tier,
"tier_id": tier_id,
"usage": usage,
"all_tiers": all_tiers,
"multi_user_enabled": settings.multi_user_enabled,
"owner_id": owner_id,
},
)