diff --git a/app/api/__init__.py b/app/api/__init__.py index 91872ecc..5645f160 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -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) diff --git a/app/api/admin_users.py b/app/api/admin_users.py index 846cf09c..2373ebc8 100644 --- a/app/api/admin_users.py +++ b/app/api/admin_users.py @@ -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() diff --git a/app/api/files.py b/app/api/files.py index b8ce40f8..35fb3022 100644 --- a/app/api/files.py +++ b/app/api/files.py @@ -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" diff --git a/app/api/subscriptions.py b/app/api/subscriptions.py new file mode 100644 index 00000000..e34dfb69 --- /dev/null +++ b/app/api/subscriptions.py @@ -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(), + } diff --git a/app/models.py b/app/models.py index 3d19c787..e0aefa57 100644 --- a/app/models.py +++ b/app/models.py @@ -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()) diff --git a/app/utils/subscription.py b/app/utils/subscription.py new file mode 100644 index 00000000..711c0888 --- /dev/null +++ b/app/utils/subscription.py @@ -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), + } diff --git a/app/views/__init__.py b/app/views/__init__.py index 9570ee7f..01cc7f91 100644 --- a/app/views/__init__.py +++ b/app/views/__init__.py @@ -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 diff --git a/app/views/general.py b/app/views/general.py index e6d70f25..8a295f55 100644 --- a/app/views/general.py +++ b/app/views/general.py @@ -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) diff --git a/app/views/subscriptions.py b/app/views/subscriptions.py new file mode 100644 index 00000000..dcd644aa --- /dev/null +++ b/app/views/subscriptions.py @@ -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, + }, + ) diff --git a/docs/SubscriptionTiers.md b/docs/SubscriptionTiers.md new file mode 100644 index 00000000..9874562f --- /dev/null +++ b/docs/SubscriptionTiers.md @@ -0,0 +1,151 @@ +# Subscription Tiers + +DocuElevate operates as a SaaS platform with four subscription tiers. When +`MULTI_USER_ENABLED=True` each user is assigned a tier that controls how many +documents they can process and how many storage destinations they can use. + +--- + +## Tier Overview + +| | Free | Starter | Professional | Business | +|---|---|---|---|---| +| **Price / month** | $0 | $9 | $29 | $79 | +| **Price / year** | $0 | $90 | $290 | $790 | +| **Lifetime file limit** | 25 files (total, ever) | Unlimited | Unlimited | Unlimited | +| **Files per day** | Unlimited* | 10 | 50 | Unlimited | +| **Files per month** | Unlimited* | 100 | 500 | Unlimited | +| **Storage destinations** | 1 | 3 | 10 | Unlimited | +| **OCR pages / month** | 50 | 500 | 2 500 | Unlimited | +| **Max file size** | 10 MB | 50 MB | 200 MB | Unlimited | +| **API access** | ✗ | ✓ | ✓ | ✓ | +| **Email ingestion** | ✗ | ✓ | ✓ | ✓ | +| **Webhooks** | ✗ | ✗ | ✓ | ✓ | +| **Support** | Community | Email | Priority email | Dedicated | + +\* Free tier is capped by the **lifetime** limit of 25 files; there is no +separate daily or monthly cap on top of that. + +--- + +## Free Tier + +The Free tier is designed for exploration. It allows up to **25 documents +processed in total across the lifetime of the account** — this is enforced +strictly; once 25 documents have been processed the upload endpoint returns +HTTP 402 and invites the user to upgrade. + +There is no per-day or per-month cap: the user can use all 25 files in a +single day if they wish. + +--- + +## Paid Tiers + +### Starter — $9/month (or $90/year) + +Good for individuals or small teams getting started with automated document +processing. Provides a meaningful step up from the free tier without a high +price commitment. + +- 10 documents/day, 100 documents/month +- 3 storage destinations (e.g. Dropbox + Google Drive + Nextcloud) +- 500 OCR pages/month +- Email ingestion and API access included + +### Professional — $29/month (or $290/year) *(Most Popular)* + +Better for growing teams that need higher volume and more integration +flexibility. + +- 50 documents/day, 500 documents/month +- 10 storage destinations +- 2 500 OCR pages/month +- All processing steps, webhooks, and priority email support + +### Business — $79/month (or $790/year) + +Best for organisations that need truly unlimited throughput with dedicated +support. + +- Unlimited documents and storage destinations +- Unlimited OCR pages +- Unlimited file size +- Custom integrations and dedicated support + +> **Contact Sales** for the Business tier — email +> `sales@docuelevate.io` with subject "Business Plan Enquiry". + +--- + +## Limit Enforcement + +Limits are enforced in real-time at the upload endpoint +(`POST /api/ui-upload`). When a user exceeds any quota: + +1. The uploaded file is discarded. +2. The endpoint returns **HTTP 402 Payment Required** with a human-readable + `detail` message explaining which limit was hit and how to upgrade. +3. The upload UI displays the error message to the user. + +Quota checks run in order: + +1. Lifetime file limit (free tier only) +2. Daily file limit +3. Monthly file limit + +--- + +## Admin Management + +Administrators can view and change each user's subscription tier from the +**Admin → Users** page (`/admin/users`). + +1. Click **Edit** next to any user. +2. Change the **Subscription Plan** dropdown. +3. Click **Save Changes**. + +The new limits take effect immediately on the user's next upload attempt. + +### API + +Admins can also manage tiers via the REST API: + +```bash +# Update a user's subscription tier +curl -X PUT /api/admin/users/user@example.com \ + -H 'Content-Type: application/json' \ + -d '{"subscription_tier": "professional", "is_blocked": false}' +``` + +--- + +## Platform Statistics + +Admins can view platform-wide statistics via: + +- **Dashboard** (`/`) — shows total files, today, this month, unique users + when `MULTI_USER_ENABLED=True`. +- **API** — `GET /api/subscriptions/platform` returns aggregate file counts + and per-tier user distribution. + +--- + +## Configuration + +Subscription tiers are defined in `app/utils/subscription.py` in the `TIERS` +dictionary. Pricing, limits, and feature lists are all set there. + +Single-user mode (`MULTI_USER_ENABLED=False`) bypasses all quota checks +entirely — the instance behaves as if every request is on the Business tier. + +--- + +## Pricing Page + +The public pricing page is available at `/pricing` and requires no +authentication. It shows the interactive tier comparison table with an +annual/monthly toggle and an FAQ section. + +Individual users can view their own subscription status, usage progress bars, +and upgrade options at `/subscription` (requires login). diff --git a/frontend/static/js/common.js b/frontend/static/js/common.js index 2d9dd486..b6bc6019 100644 --- a/frontend/static/js/common.js +++ b/frontend/static/js/common.js @@ -141,25 +141,35 @@ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', fun if (authSection) { authSection.textContent = ''; // Clear existing content const container = document.createElement('div'); - container.className = 'flex items-center'; + container.className = 'flex items-center gap-2'; const img = document.createElement('img'); img.src = data.picture; img.alt = 'Avatar'; - img.className = 'w-8 h-8 rounded-full mr-2'; + img.className = 'w-8 h-8 rounded-full'; const span = document.createElement('span'); span.textContent = displayName; + + // Subscription link + const planLink = document.createElement('a'); + planLink.href = '/subscription'; + planLink.className = 'text-xs text-indigo-600 hover:text-indigo-800 font-medium hidden md:inline'; + planLink.title = 'My subscription'; + const planIcon = document.createElement('i'); + planIcon.className = 'fas fa-layer-group'; + planLink.appendChild(planIcon); const logoutLink = document.createElement('a'); logoutLink.href = '/logout'; - logoutLink.className = 'ml-3 text-red-600 hover:text-red-800'; + logoutLink.className = 'text-red-600 hover:text-red-800'; const icon = document.createElement('i'); icon.className = 'fas fa-sign-out-alt'; logoutLink.appendChild(icon); container.appendChild(img); container.appendChild(span); + container.appendChild(planLink); container.appendChild(logoutLink); authSection.appendChild(container); } diff --git a/frontend/templates/admin_users.html b/frontend/templates/admin_users.html index f720348a..21c11568 100644 --- a/frontend/templates/admin_users.html +++ b/frontend/templates/admin_users.html @@ -68,6 +68,7 @@ Display Name Documents Last Upload + Plan Upload Limit Status Actions @@ -76,14 +77,14 @@