From 70dd35dec4a711e5b51396f86f24a4225b1b186b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:32:27 +0000 Subject: [PATCH 1/9] Initial plan From 179f6125e80d8abc5818eb1f4c32fd1c1538f3f1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:53:12 +0000 Subject: [PATCH 2/9] 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> --- app/api/__init__.py | 2 + app/api/admin_users.py | 18 + app/api/files.py | 18 +- app/api/subscriptions.py | 153 +++++++ app/models.py | 4 + app/utils/subscription.py | 303 ++++++++++++++ app/views/__init__.py | 2 + app/views/general.py | 74 +++- app/views/subscriptions.py | 64 +++ docs/SubscriptionTiers.md | 151 +++++++ frontend/static/js/common.js | 16 +- frontend/templates/admin_users.html | 53 ++- frontend/templates/base.html | 2 + frontend/templates/index.html | 298 ++++++++++++++ frontend/templates/pricing.html | 373 ++++++++++++++++++ frontend/templates/subscription.html | 218 ++++++++++ .../versions/014_add_subscription_tiers.py | 30 ++ tests/test_subscription.py | 253 ++++++++++++ 18 files changed, 2014 insertions(+), 18 deletions(-) create mode 100644 app/api/subscriptions.py create mode 100644 app/utils/subscription.py create mode 100644 app/views/subscriptions.py create mode 100644 docs/SubscriptionTiers.md create mode 100644 frontend/templates/pricing.html create mode 100644 frontend/templates/subscription.html create mode 100644 migrations/versions/014_add_subscription_tiers.py create mode 100644 tests/test_subscription.py 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 @@ + + + {% if tier.trial_days > 0 %} +
+ + {{ tier.trial_days }}-day free trial — no credit card required +
+ {% endif %} @@ -110,10 +118,6 @@ {{ tier.cta }} - {% elif tier.id == 'business' %} - {{ tier.cta }} {% elif tier.highlight %} + + Mailboxes (ingestion sources) + {% for tier in tiers %} + + {% if tier.max_mailboxes == 0 and tier.id == 'free' %} + + {% elif tier.max_mailboxes == 0 %} + Unlimited + {% else %} + {{ tier.max_mailboxes }} + {% endif %} + + {% endfor %} + + Webhooks @@ -323,15 +342,19 @@ ("What counts as a 'processed file'?", "Every document you upload and run through the DocuElevate pipeline counts as one processed file — including OCR, AI metadata extraction, and storage delivery."), ("What happens when I reach the Free tier lifetime limit?", - "Once your 25-file lifetime quota is reached you will see a friendly upgrade prompt on the upload page and any further upload attempts will return a payment-required error until you upgrade to a paid plan."), + "Once your 50-file lifetime quota is reached you will see a friendly upgrade prompt on the upload page and any further upload attempts will return a payment-required error until you upgrade to a paid plan."), ("Can I change my plan at any time?", "Yes. Upgrades take effect immediately. Downgrades take effect at the start of the next billing cycle. Unused quota does not roll over between billing periods."), ("Is there an annual discount?", - "Yes — paying annually saves approximately 17% compared to monthly billing. The savings are shown in the annual pricing above."), - ("Do you offer a trial for paid plans?", - "The Free tier lets you try DocuElevate with up to 25 files at no cost and with no credit card required. Paid trials can be arranged — contact sales."), + "Yes — paying annually saves approximately 20 % compared to monthly billing (≈ 2½ months free). The exact annual price and per-month equivalent are shown when you toggle to Annual above."), + ("Is there a free trial for paid plans?", + "Yes! All three paid plans include a 30-day free trial — no credit card required. You can upgrade from the Free tier or start a trial directly from any paid plan card above."), ("What is a 'storage destination'?", "A storage destination is any cloud or self-hosted storage you configure as an output — Dropbox, Google Drive, OneDrive, Nextcloud, S3, SFTP, FTP, WebDAV, or Paperless-ngx each count as one destination."), + ("What is a 'mailbox'?", + "A mailbox is an email address DocuElevate monitors for incoming documents. Any attachment arriving at a configured mailbox is automatically processed through the pipeline. The Free tier does not include email ingestion."), + ("Are prices inclusive of VAT?", + "Listed prices are exclusive of VAT. Customers in Germany are charged 19 % Mehrwertsteuer (MwSt) at checkout. EU business customers outside Germany apply the reverse-charge mechanism. Non-EU customers are not subject to German VAT."), ] %} {% for q, a in faqs %} diff --git a/tests/test_subscription.py b/tests/test_subscription.py index f8091416..d0e2fce5 100644 --- a/tests/test_subscription.py +++ b/tests/test_subscription.py @@ -50,7 +50,7 @@ def test_default_tier_is_free(): def test_get_tier_returns_correct_dict(): t = get_tier("starter") assert t["id"] == "starter" - assert t["price_monthly"] == 9 + assert t["price_monthly"] == 2.99 @pytest.mark.unit @@ -70,8 +70,10 @@ def test_get_all_tiers_returns_four(): def test_all_tiers_have_required_fields(): required = [ "id", "name", "tagline", "price_monthly", "price_yearly", + "trial_days", "lifetime_file_limit", "daily_upload_limit", "monthly_upload_limit", "max_storage_destinations", "max_ocr_pages_monthly", "max_file_size_mb", + "max_mailboxes", "features", "cta", ] for tid, tier in TIERS.items(): @@ -81,19 +83,54 @@ def test_all_tiers_have_required_fields(): @pytest.mark.unit def test_free_tier_has_lifetime_limit(): - """Free tier must have a non-zero lifetime file limit.""" - assert TIERS["free"]["lifetime_file_limit"] > 0 + """Free tier must have a non-zero lifetime file limit of 50.""" + assert TIERS["free"]["lifetime_file_limit"] == 50 @pytest.mark.unit -def test_business_tier_is_unlimited(): - """Business tier must have 0 (unlimited) for all limits.""" +def test_free_tier_ocr_pages(): + """Free tier must have 150 OCR pages.""" + assert TIERS["free"]["max_ocr_pages_monthly"] == 150 + + +@pytest.mark.unit +def test_free_tier_has_no_mailboxes(): + """Free tier must not allow email ingestion mailboxes.""" + assert TIERS["free"]["max_mailboxes"] == 0 + + +@pytest.mark.unit +def test_business_tier_has_highest_limits(): + """Business tier must have the highest limits of all paid tiers.""" t = TIERS["business"] + # lifetime, daily, monthly: no hard cap (0 = unlimited) for lifetime; daily/monthly capped assert t["lifetime_file_limit"] == 0 - assert t["daily_upload_limit"] == 0 - assert t["monthly_upload_limit"] == 0 - assert t["max_storage_destinations"] == 0 - assert t["max_ocr_pages_monthly"] == 0 + assert t["daily_upload_limit"] == 30 + assert t["monthly_upload_limit"] == 300 + assert t["max_ocr_pages_monthly"] == 1500 + # unlimited mailboxes + assert t["max_mailboxes"] == 0 + + +@pytest.mark.unit +def test_mailbox_limits_increase_by_tier(): + """Mailbox limits must increase across tiers: free=0, starter=1, professional=3, business=0(∞).""" + assert TIERS["free"]["max_mailboxes"] == 0 + assert TIERS["starter"]["max_mailboxes"] == 1 + assert TIERS["professional"]["max_mailboxes"] == 3 + assert TIERS["business"]["max_mailboxes"] == 0 # 0 means unlimited + + +@pytest.mark.unit +def test_paid_tiers_have_trial_days(): + """All paid tiers must have a 30-day free trial.""" + for tid in ["starter", "professional", "business"]: + assert TIERS[tid]["trial_days"] == 30, f"Tier '{tid}' missing 30-day trial" + + +@pytest.mark.unit +def test_free_tier_has_no_trial(): + assert TIERS["free"]["trial_days"] == 0 @pytest.mark.unit @@ -160,16 +197,16 @@ def test_check_upload_skipped_without_tier(): @pytest.mark.unit def test_check_upload_raises_when_lifetime_exceeded(): - """Free tier: should raise QuotaExceeded when lifetime limit is hit.""" + """Free tier: should raise QuotaExceeded when lifetime limit (50) is hit.""" db = MagicMock() - with patch("app.utils.subscription.get_lifetime_file_count", return_value=25): + with patch("app.utils.subscription.get_lifetime_file_count", return_value=50): with pytest.raises(QuotaExceeded) as exc_info: check_upload_allowed(db, "user@example.com", "free") assert exc_info.value.limit_type == "lifetime" - assert exc_info.value.limit_value == 25 - assert exc_info.value.current_value == 25 + assert exc_info.value.limit_value == 50 + assert exc_info.value.current_value == 50 @pytest.mark.unit @@ -181,11 +218,11 @@ def test_check_upload_passes_below_lifetime_limit(): @pytest.mark.unit def test_check_upload_raises_when_daily_exceeded(): - """Starter tier: should raise QuotaExceeded when daily limit is hit.""" + """Starter tier: should raise QuotaExceeded when daily limit (5) is hit.""" db = MagicMock() with patch("app.utils.subscription.get_lifetime_file_count", return_value=0), \ - patch("app.utils.subscription.get_today_file_count", return_value=10): + patch("app.utils.subscription.get_today_file_count", return_value=5): with pytest.raises(QuotaExceeded) as exc_info: check_upload_allowed(db, "user@example.com", "starter") @@ -194,12 +231,12 @@ def test_check_upload_raises_when_daily_exceeded(): @pytest.mark.unit def test_check_upload_raises_when_monthly_exceeded(): - """Starter tier: should raise QuotaExceeded when monthly limit is hit.""" + """Starter tier: should raise QuotaExceeded when monthly limit (50) is hit.""" db = MagicMock() with patch("app.utils.subscription.get_lifetime_file_count", return_value=0), \ patch("app.utils.subscription.get_today_file_count", return_value=0), \ - patch("app.utils.subscription.get_month_file_count", return_value=100): + patch("app.utils.subscription.get_month_file_count", return_value=50): with pytest.raises(QuotaExceeded) as exc_info: check_upload_allowed(db, "user@example.com", "starter") @@ -207,16 +244,30 @@ def test_check_upload_raises_when_monthly_exceeded(): @pytest.mark.unit -def test_check_upload_business_tier_never_raises(): - """Business tier has no limits — check_upload_allowed must never raise.""" +def test_check_upload_business_tier_within_limits(): + """Business tier: upload is allowed as long as counts are below the capped limits.""" db = MagicMock() - # Even with absurdly high counts, business tier is unlimited - with patch("app.utils.subscription.get_lifetime_file_count", return_value=999999), \ - patch("app.utils.subscription.get_today_file_count", return_value=999999), \ - patch("app.utils.subscription.get_month_file_count", return_value=999999): + # Use counts well below Business limits (30/day, 300/mo) + with patch("app.utils.subscription.get_lifetime_file_count", return_value=0), \ + patch("app.utils.subscription.get_today_file_count", return_value=10), \ + patch("app.utils.subscription.get_month_file_count", return_value=100): check_upload_allowed(db, "user@example.com", "business") # must not raise +@pytest.mark.unit +def test_check_upload_business_tier_raises_when_daily_exceeded(): + """Business tier: should raise QuotaExceeded when daily limit (30) is hit.""" + db = MagicMock() + + with patch("app.utils.subscription.get_lifetime_file_count", return_value=0), \ + patch("app.utils.subscription.get_today_file_count", return_value=30): + with pytest.raises(QuotaExceeded) as exc_info: + check_upload_allowed(db, "user@example.com", "business") + + assert exc_info.value.limit_type == "daily" + assert exc_info.value.limit_value == 30 + + # --------------------------------------------------------------------------- # get_user_usage (mocked DB) # --------------------------------------------------------------------------- From ea7fffa3a126ba819261be36052b8f609743df98 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 18:48:27 +0000 Subject: [PATCH 6/9] feat(subscriptions): database-backed plan designer with admin CRUD and overage buffer - Add SubscriptionPlan model and subscription_plans table (migration 015) - Add billing cycle/period/allow_overage fields to UserProfile (migration 016) - Add subscription_overage_percent config field (replaces overage_factor) - Rewrite check_upload_allowed: use overage_percent, yearly carry-over, no daily cap - Add seed_default_plans(), _plan_to_dict(), get_year_file_count(), _months_elapsed() - Update get_tier/get_all_tiers to be DB-first with TIER_DEFAULTS fallback - Add TIER_DEFAULTS alias (TIERS kept for backward compat) - New /api/plans/ CRUD endpoints (admin-only except list/get) - New /admin/plans Plan Designer page with Alpine.js UI - Add Plan Designer link to admin navigation in base.html - Remove 'Files per day' row from pricing comparison table - Add billing cycle + period start to admin users edit modal - Seed default plans on startup in lifespan handler - Rewrite docs/SubscriptionTiers.md with full plan/overage/API docs - Fix all tests in test_subscription.py (remove daily cap tests, add overage/carry-over tests) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/api/__init__.py | 2 + app/api/admin_users.py | 30 + app/api/plans.py | 273 ++++++++ app/config.py | 13 + app/main.py | 13 + app/models.py | 55 +- app/utils/subscription.py | 210 ++++-- app/views/__init__.py | 4 +- app/views/plans.py | 18 + app/views/subscriptions.py | 8 +- docs/SubscriptionTiers.md | 182 +++--- frontend/templates/admin_plans.html | 608 ++++++++++++++++++ frontend/templates/admin_users.html | 43 +- frontend/templates/base.html | 6 + frontend/templates/pricing.html | 13 - .../versions/015_add_subscription_plans.py | 70 ++ .../versions/016_add_userprofile_billing.py | 45 ++ tests/test_subscription.py | 295 +++++++-- 18 files changed, 1653 insertions(+), 235 deletions(-) create mode 100644 app/api/plans.py create mode 100644 app/views/plans.py create mode 100644 frontend/templates/admin_plans.html create mode 100644 migrations/versions/015_add_subscription_plans.py create mode 100644 migrations/versions/016_add_userprofile_billing.py diff --git a/app/api/__init__.py b/app/api/__init__.py index 5645f160..ac916de2 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -17,6 +17,7 @@ from app.api.google_drive import router as google_drive_router from app.api.logs import router as logs_router from app.api.onedrive import router as onedrive_router from app.api.openai import router as openai_router +from app.api.plans import router as plans_router from app.api.process import router as process_router from app.api.queue import router as queue_router from app.api.saved_searches import router as saved_searches_router @@ -58,3 +59,4 @@ router.include_router(duplicates_router) router.include_router(webhooks_router) router.include_router(database_router) router.include_router(subscriptions_router) +router.include_router(plans_router) diff --git a/app/api/admin_users.py b/app/api/admin_users.py index 2373ebc8..f80d463f 100644 --- a/app/api/admin_users.py +++ b/app/api/admin_users.py @@ -5,6 +5,7 @@ administrators can inspect, configure, and manage users in multi-user mode. """ import logging +from datetime import datetime from typing import Annotated, Any from fastapi import APIRouter, Depends, HTTPException, Query, Request, status @@ -55,6 +56,9 @@ class UserProfileUpsert(BaseModel): default="free", description="Subscription tier: free | starter | professional | business", ) + subscription_billing_cycle: str = Field(default="monthly", pattern="^(monthly|yearly)$") + subscription_period_start: datetime | None = None + allow_overage: bool = False class UserProfileResponse(BaseModel): @@ -67,6 +71,9 @@ class UserProfileResponse(BaseModel): notes: str | None is_blocked: bool subscription_tier: str | None + subscription_billing_cycle: str + subscription_period_start: str | None + allow_overage: bool created_at: str | None updated_at: str | None @@ -82,6 +89,9 @@ class UserSummary(BaseModel): notes: str | None is_blocked: bool subscription_tier: str | None + subscription_billing_cycle: str | None + subscription_period_start: str | None + allow_overage: bool profile_id: int | None document_count: int last_upload: str | None @@ -106,6 +116,11 @@ def _profile_to_dict(profile: UserProfile) -> dict[str, Any]: "notes": profile.notes, "is_blocked": profile.is_blocked, "subscription_tier": profile.subscription_tier or "free", + "subscription_billing_cycle": profile.subscription_billing_cycle or "monthly", + "subscription_period_start": profile.subscription_period_start.isoformat() + if profile.subscription_period_start + else None, + "allow_overage": bool(profile.allow_overage), "created_at": profile.created_at.isoformat() if profile.created_at else None, "updated_at": profile.updated_at.isoformat() if profile.updated_at else None, } @@ -172,6 +187,13 @@ def list_users( "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", + "subscription_billing_cycle": (profile.subscription_billing_cycle or "monthly") + if profile + else "monthly", + "subscription_period_start": profile.subscription_period_start.isoformat() + if (profile and profile.subscription_period_start) + else None, + "allow_overage": bool(profile.allow_overage) if profile else False, "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, @@ -208,6 +230,11 @@ def get_user(user_id: str, db: DbSession, _admin: AdminUser) -> dict[str, Any]: "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", + "subscription_billing_cycle": (profile.subscription_billing_cycle or "monthly") if profile else "monthly", + "subscription_period_start": profile.subscription_period_start.isoformat() + if (profile and profile.subscription_period_start) + else None, + "allow_overage": bool(profile.allow_overage) if profile else False, "profile_id": profile.id if profile else None, "document_count": doc_count, "last_upload": last_upload, @@ -235,6 +262,9 @@ def upsert_user_profile( profile.daily_upload_limit = body.daily_upload_limit profile.notes = body.notes profile.is_blocked = body.is_blocked + profile.subscription_billing_cycle = body.subscription_billing_cycle + profile.subscription_period_start = body.subscription_period_start + profile.allow_overage = body.allow_overage if body.subscription_tier is not None: from app.utils.subscription import TIERS diff --git a/app/api/plans.py b/app/api/plans.py new file mode 100644 index 00000000..844b9326 --- /dev/null +++ b/app/api/plans.py @@ -0,0 +1,273 @@ +"""REST API for subscription plan CRUD. + +Endpoints: + GET /api/plans/ — list active plans (public) + GET /api/plans/admin — list all plans inc. inactive (admin only) + POST /api/plans/ — create plan (admin only) + GET /api/plans/{plan_id} — get single active plan (public) + PUT /api/plans/{plan_id} — update plan (admin only) + DELETE /api/plans/{plan_id} — delete plan (admin only) + POST /api/plans/seed — seed default plans (admin only) + POST /api/plans/reorder — set sort_order for multiple plans (admin only) +""" + +import json +import logging +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models import SubscriptionPlan + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/plans", tags=["plans"]) + +DbSession = Annotated[Session, Depends(get_db)] + + +# --------------------------------------------------------------------------- +# Auth helper (admin-only) +# --------------------------------------------------------------------------- + + +def _require_admin(request: Request) -> dict: + """Ensure the caller is an admin. Raises 403 otherwise.""" + 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 + + +AdminUser = Annotated[dict, Depends(_require_admin)] + + +# --------------------------------------------------------------------------- +# Pydantic schemas +# --------------------------------------------------------------------------- + + +class PlanUpsert(BaseModel): + """Body for creating or updating a subscription plan.""" + + name: str + tagline: str | None = None + price_monthly: float = 0.0 + price_yearly: float = 0.0 + trial_days: int = 0 + lifetime_file_limit: int = 0 + daily_upload_limit: int = 0 + monthly_upload_limit: int = 0 + max_storage_destinations: int = 0 + max_ocr_pages_monthly: int = 0 + max_file_size_mb: int = 0 + max_mailboxes: int = 0 + overage_percent: int = Field(default=20, ge=0, le=200) + allow_overage_billing: bool = False + overage_price_per_doc: float | None = None + overage_price_per_ocr_page: float | None = None + is_active: bool = True + is_highlighted: bool = False + badge_text: str | None = None + cta_text: str = "Get started" + sort_order: int = 0 + features: list[str] = [] + api_access: bool = False + + +class ReorderBody(BaseModel): + """Body for reordering plans.""" + + order: list[str] + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _plan_to_response(plan: SubscriptionPlan) -> dict[str, Any]: + features: list[str] = [] + if plan.features: + try: + features = json.loads(plan.features) + except (json.JSONDecodeError, TypeError): + features = [] + return { + "id": plan.id, + "plan_id": plan.plan_id, + "name": plan.name, + "tagline": plan.tagline, + "price_monthly": plan.price_monthly, + "price_yearly": plan.price_yearly, + "trial_days": plan.trial_days, + "lifetime_file_limit": plan.lifetime_file_limit, + "daily_upload_limit": plan.daily_upload_limit, + "monthly_upload_limit": plan.monthly_upload_limit, + "max_storage_destinations": plan.max_storage_destinations, + "max_ocr_pages_monthly": plan.max_ocr_pages_monthly, + "max_file_size_mb": plan.max_file_size_mb, + "max_mailboxes": plan.max_mailboxes, + "overage_percent": plan.overage_percent, + "allow_overage_billing": plan.allow_overage_billing, + "overage_price_per_doc": plan.overage_price_per_doc, + "overage_price_per_ocr_page": plan.overage_price_per_ocr_page, + "is_active": plan.is_active, + "is_highlighted": plan.is_highlighted, + "badge_text": plan.badge_text, + "cta_text": plan.cta_text, + "sort_order": plan.sort_order, + "features": features, + "api_access": plan.api_access, + "created_at": plan.created_at.isoformat() if plan.created_at else None, + "updated_at": plan.updated_at.isoformat() if plan.updated_at else None, + } + + +def _apply_body(plan: SubscriptionPlan, body: PlanUpsert) -> None: + """Apply PlanUpsert fields onto a SubscriptionPlan ORM object.""" + plan.name = body.name + plan.tagline = body.tagline + plan.price_monthly = body.price_monthly + plan.price_yearly = body.price_yearly + plan.trial_days = body.trial_days + plan.lifetime_file_limit = body.lifetime_file_limit + plan.daily_upload_limit = body.daily_upload_limit + plan.monthly_upload_limit = body.monthly_upload_limit + plan.max_storage_destinations = body.max_storage_destinations + plan.max_ocr_pages_monthly = body.max_ocr_pages_monthly + plan.max_file_size_mb = body.max_file_size_mb + plan.max_mailboxes = body.max_mailboxes + plan.overage_percent = body.overage_percent + plan.allow_overage_billing = body.allow_overage_billing + plan.overage_price_per_doc = body.overage_price_per_doc + plan.overage_price_per_ocr_page = body.overage_price_per_ocr_page + plan.is_active = body.is_active + plan.is_highlighted = body.is_highlighted + plan.badge_text = body.badge_text + plan.cta_text = body.cta_text + plan.sort_order = body.sort_order + plan.features = json.dumps(body.features) + plan.api_access = body.api_access + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + + +@router.get("/", summary="List active plans (public)") +def list_active_plans(db: DbSession) -> dict[str, Any]: + """Return all active plans in sort order. Public endpoint — no auth required.""" + plans = ( + db.query(SubscriptionPlan) + .filter(SubscriptionPlan.is_active.is_(True)) + .order_by(SubscriptionPlan.sort_order) + .all() + ) + return {"plans": [_plan_to_response(p) for p in plans]} + + +@router.get("/admin", summary="List all plans including inactive (admin only)") +def list_all_plans(db: DbSession, _admin: AdminUser) -> dict[str, Any]: + """Return all plans (active and inactive) in sort order. Admin only.""" + plans = db.query(SubscriptionPlan).order_by(SubscriptionPlan.sort_order).all() + return {"plans": [_plan_to_response(p) for p in plans]} + + +@router.post("/seed", summary="Seed default plans (admin only)", status_code=status.HTTP_200_OK) +def seed_plans(db: DbSession, _admin: AdminUser) -> dict[str, Any]: + """Seed the subscription_plans table from TIER_DEFAULTS. No-op if plans already exist.""" + from app.utils.subscription import seed_default_plans + + inserted = seed_default_plans(db) + return {"inserted": inserted, "message": f"Seeded {inserted} default plan(s)."} + + +@router.post("/reorder", summary="Reorder plans (admin only)") +def reorder_plans(body: ReorderBody, db: DbSession, _admin: AdminUser) -> dict[str, Any]: + """Update sort_order for each plan_id in *body.order* (position = index in list).""" + updated = 0 + for sort_order, plan_id in enumerate(body.order): + plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.plan_id == plan_id).first() + if plan: + plan.sort_order = sort_order + updated += 1 + try: + db.commit() + except Exception: + db.rollback() + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to reorder plans") + return {"updated": updated} + + +@router.post("/", summary="Create a new plan (admin only)", status_code=status.HTTP_201_CREATED) +def create_plan(plan_id: str, body: PlanUpsert, db: DbSession, _admin: AdminUser) -> dict[str, Any]: + """Create a new subscription plan with the given *plan_id* slug.""" + existing = db.query(SubscriptionPlan).filter(SubscriptionPlan.plan_id == plan_id).first() + if existing: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Plan '{plan_id}' already exists.", + ) + plan = SubscriptionPlan(plan_id=plan_id) + _apply_body(plan, body) + db.add(plan) + try: + db.commit() + db.refresh(plan) + except Exception: + db.rollback() + raise + logger.info("Admin created subscription plan '%s'", plan_id) + return _plan_to_response(plan) + + +@router.get("/{plan_id}", summary="Get a single active plan (public)") +def get_plan(plan_id: str, db: DbSession) -> dict[str, Any]: + """Return a single active plan by plan_id. Public endpoint.""" + plan = ( + db.query(SubscriptionPlan) + .filter( + SubscriptionPlan.plan_id == plan_id, + SubscriptionPlan.is_active.is_(True), + ) + .first() + ) + if not plan: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Plan '{plan_id}' not found.") + return _plan_to_response(plan) + + +@router.put("/{plan_id}", summary="Update an existing plan (admin only)") +def update_plan(plan_id: str, body: PlanUpsert, db: DbSession, _admin: AdminUser) -> dict[str, Any]: + """Update an existing subscription plan. Admin only.""" + plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.plan_id == plan_id).first() + if not plan: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Plan '{plan_id}' not found.") + _apply_body(plan, body) + try: + db.commit() + db.refresh(plan) + except Exception: + db.rollback() + raise + logger.info("Admin updated subscription plan '%s'", plan_id) + return _plan_to_response(plan) + + +@router.delete("/{plan_id}", summary="Delete a plan (admin only)", status_code=status.HTTP_204_NO_CONTENT) +def delete_plan(plan_id: str, db: DbSession, _admin: AdminUser) -> None: + """Delete a subscription plan. Admin only.""" + plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.plan_id == plan_id).first() + if not plan: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Plan '{plan_id}' not found.") + try: + db.delete(plan) + db.commit() + except Exception: + db.rollback() + raise + logger.info("Admin deleted subscription plan '%s'", plan_id) diff --git a/app/config.py b/app/config.py index b678a97e..d0322e82 100644 --- a/app/config.py +++ b/app/config.py @@ -153,6 +153,19 @@ class Settings(BaseSettings): ), ) + subscription_overage_percent: int = Field( + default=20, + ge=0, + le=200, + description=( + "Soft-limit overage buffer in percent (0–200). The announced monthly quota is " + "increased by this percentage for actual enforcement. E.g. 20 means a 150-doc/month " + "plan enforces at 180 docs (150 × 1.20). Set 0 to enforce exactly at the announced " + "limit. Per-plan overage_percent (set in Plan Designer) overrides this global default. " + "Default: 20." + ), + ) + # Authentik authentik_client_id: Optional[str] = None authentik_client_secret: Optional[str] = None diff --git a/app/main.py b/app/main.py index 10f305d2..5f39ae2c 100644 --- a/app/main.py +++ b/app/main.py @@ -99,6 +99,19 @@ async def lifespan(app: FastAPI): # Send startup notification notify_startup() + # Seed default subscription plans if none exist + try: + from app.database import SessionLocal as _SessionLocal + from app.utils.subscription import seed_default_plans as _seed_plans + + _db_seed = _SessionLocal() + try: + _seed_plans(_db_seed) + finally: + _db_seed.close() + except Exception: + logging.debug("Subscription plan seeding skipped — DB may not be ready yet") # noqa: S110 + # Application is now running yield diff --git a/app/models.py b/app/models.py index e0aefa57..b5857bb5 100644 --- a/app/models.py +++ b/app/models.py @@ -1,6 +1,6 @@ # app/models.py -from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func +from sqlalchemy import Boolean, Column, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func from app.database import Base @@ -204,5 +204,58 @@ class UserProfile(Base): # NULL is treated as "free" by the subscription utility. subscription_tier = Column(String(50), nullable=True, default="free") + # Billing cycle and overage settings (added in migration 016) + subscription_billing_cycle = Column(String(10), nullable=False, default="monthly", server_default="monthly") + subscription_period_start = Column(DateTime(timezone=True), nullable=True) + allow_overage = Column(Boolean, nullable=False, default=False, server_default="0") + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + +class SubscriptionPlan(Base): + """Dynamically configurable subscription plan stored in the database. + + Plans are shown on the public /pricing page and assigned to users via + UserProfile.subscription_tier (which stores plan_id). On first start the + four default plans are seeded from TIER_DEFAULTS in app/utils/subscription.py. + """ + + __tablename__ = "subscription_plans" + + id = Column(Integer, primary_key=True, index=True) + plan_id = Column(String(50), unique=True, nullable=False, index=True) + name = Column(String(100), nullable=False) + tagline = Column(String(255), nullable=True) + + # Pricing + price_monthly = Column(Float, nullable=False, default=0.0) + price_yearly = Column(Float, nullable=False, default=0.0) + trial_days = Column(Integer, nullable=False, default=0) + + # Volume limits (0 = unlimited) + lifetime_file_limit = Column(Integer, nullable=False, default=0) + daily_upload_limit = Column(Integer, nullable=False, default=0) + monthly_upload_limit = Column(Integer, nullable=False, default=0) + max_storage_destinations = Column(Integer, nullable=False, default=0) + max_ocr_pages_monthly = Column(Integer, nullable=False, default=0) + max_file_size_mb = Column(Integer, nullable=False, default=0) + max_mailboxes = Column(Integer, nullable=False, default=0) + + # Overage configuration + overage_percent = Column(Integer, nullable=False, default=20) + allow_overage_billing = Column(Boolean, nullable=False, default=False) + overage_price_per_doc = Column(Float, nullable=True) + overage_price_per_ocr_page = Column(Float, nullable=True) + + # Display / marketing + is_active = Column(Boolean, nullable=False, default=True) + is_highlighted = Column(Boolean, nullable=False, default=False) + badge_text = Column(String(50), nullable=True) + cta_text = Column(String(100), nullable=False, default="Get started") + sort_order = Column(Integer, nullable=False, default=0) + features = Column(Text, nullable=True) # JSON-encoded list[str] + api_access = Column(Boolean, nullable=False, default=False) + 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 index 8723252b..d7dd92c0 100644 --- a/app/utils/subscription.py +++ b/app/utils/subscription.py @@ -3,9 +3,9 @@ Subscription tier definitions and enforcement utilities for DocuElevate SaaS. Four tiers (prices ex-VAT; German customers +19 % MwSt): - free $0/mo — 50 lifetime docs, 150 lifetime OCR pages, 1 dest - - starter $2.99/mo — 5/day, 50/mo, 300 OCR pp/mo, 2 dests, 1 mailbox - - professional $5.99/mo — 15/day, 150/mo, 750 OCR pp/mo, 5 dests, 3 mailboxes - - business $7.99/mo — 30/day, 300/mo, 1500 OCR pp/mo, 10 dests, unlimited mailboxes + - starter $2.99/mo — 50/mo, 300 OCR pp/mo, 2 dests, 1 mailbox + - professional $5.99/mo — 150/mo, 750 OCR pp/mo, 5 dests, 3 mailboxes + - business $7.99/mo — 300/mo, 1500 OCR pp/mo, 10 dests, unlimited mailboxes Limits use 0 to represent "unlimited". All paid tiers include a 30-day free trial (trial_days field). @@ -34,13 +34,15 @@ from typing import Any from sqlalchemy import func from sqlalchemy.orm import Session +from app.config import settings + logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Tier catalogue # --------------------------------------------------------------------------- -TIERS: dict[str, dict[str, Any]] = { +TIER_DEFAULTS: dict[str, dict[str, Any]] = { "free": { "id": "free", "name": "Free", @@ -161,6 +163,9 @@ TIERS: dict[str, dict[str, Any]] = { }, } +# Backward-compatible alias +TIERS = TIER_DEFAULTS + # Display order for the pricing page TIER_ORDER = ["free", "starter", "professional", "business"] @@ -168,19 +173,135 @@ TIER_ORDER = ["free", "starter", "professional", "business"] DEFAULT_TIER = "free" +# --------------------------------------------------------------------------- +# DB → dict conversion +# --------------------------------------------------------------------------- + + +def _plan_to_dict(plan: Any) -> dict[str, Any]: + """Convert a SubscriptionPlan ORM object to the same dict shape as TIER_DEFAULTS entries.""" + import json + + features: list[str] = [] + if plan.features: + try: + features = json.loads(plan.features) + except (json.JSONDecodeError, TypeError): + features = [] + return { + "id": plan.plan_id, + "name": plan.name, + "tagline": plan.tagline or "", + "price_monthly": plan.price_monthly, + "price_yearly": plan.price_yearly, + "trial_days": plan.trial_days, + "highlight": plan.is_highlighted, + "lifetime_file_limit": plan.lifetime_file_limit, + "daily_upload_limit": plan.daily_upload_limit, + "monthly_upload_limit": plan.monthly_upload_limit, + "max_storage_destinations": plan.max_storage_destinations, + "max_ocr_pages_monthly": plan.max_ocr_pages_monthly, + "max_file_size_mb": plan.max_file_size_mb, + "max_mailboxes": plan.max_mailboxes, + "api_access": plan.api_access, + "features": features, + "cta": plan.cta_text or "Get started", + "badge": plan.badge_text, + "overage_percent": plan.overage_percent, + "allow_overage_billing": plan.allow_overage_billing, + } + + # --------------------------------------------------------------------------- # 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_tier(tier_id: str, db: Session | None = None) -> dict[str, Any]: + """Return plan config dict; DB-first when db is provided, falls back to TIER_DEFAULTS.""" + if db is not None: + from app.models import SubscriptionPlan + + plan = ( + db.query(SubscriptionPlan) + .filter( + SubscriptionPlan.plan_id == tier_id, + SubscriptionPlan.is_active.is_(True), + ) + .first() + ) + if plan is not None: + return _plan_to_dict(plan) + return TIER_DEFAULTS.get(tier_id, TIER_DEFAULTS["free"]) -def get_all_tiers() -> list[dict[str, Any]]: - """Return tiers in display order.""" - return [TIERS[tid] for tid in TIER_ORDER] +def get_all_tiers(db: Session | None = None) -> list[dict[str, Any]]: + """Return plans in display order; DB-first when db is provided.""" + if db is not None: + from app.models import SubscriptionPlan + + plans = ( + db.query(SubscriptionPlan) + .filter(SubscriptionPlan.is_active.is_(True)) + .order_by(SubscriptionPlan.sort_order) + .all() + ) + if plans: + return [_plan_to_dict(p) for p in plans] + return [TIER_DEFAULTS[tid] for tid in TIER_ORDER] + + +def seed_default_plans(db: Session) -> int: + """Seed subscription_plans table from TIER_DEFAULTS if the table is empty. + + Called at application startup. Returns the number of plans inserted (0 if already seeded). + """ + import json + + from app.models import SubscriptionPlan + + try: + if db.query(SubscriptionPlan).count() > 0: + return 0 + except Exception: + return 0 # table may not exist yet during first migration + + inserted = 0 + for sort_order, (_, tier) in enumerate(TIER_DEFAULTS.items()): + plan = SubscriptionPlan( + plan_id=tier["id"], + name=tier["name"], + tagline=tier.get("tagline", ""), + price_monthly=tier["price_monthly"], + price_yearly=tier["price_yearly"], + trial_days=tier.get("trial_days", 0), + is_highlighted=tier.get("highlight", False), + badge_text=tier.get("badge"), + cta_text=tier.get("cta", "Get started"), + lifetime_file_limit=tier["lifetime_file_limit"], + daily_upload_limit=tier["daily_upload_limit"], + monthly_upload_limit=tier["monthly_upload_limit"], + max_storage_destinations=tier["max_storage_destinations"], + max_ocr_pages_monthly=tier["max_ocr_pages_monthly"], + max_file_size_mb=tier["max_file_size_mb"], + max_mailboxes=tier.get("max_mailboxes", 0), + api_access=tier.get("api_access", False), + features=json.dumps(tier.get("features", [])), + overage_percent=20, + allow_overage_billing=False, + sort_order=sort_order, + is_active=True, + ) + db.add(plan) + inserted += 1 + try: + db.commit() + logger.info("Seeded %d default subscription plans", inserted) + except Exception as exc: + db.rollback() + logger.error("Failed to seed subscription plans: %s", exc) + inserted = 0 + return inserted # --------------------------------------------------------------------------- @@ -235,11 +356,7 @@ def get_month_file_count(db: Session, owner_id: str) -> int: def get_year_file_count(db: Session, owner_id: str, period_start: datetime) -> int: - """Files processed since the start of the current subscription period. - - Used for yearly-subscription carry-over: compares cumulative usage against the - cumulative monthly budget since the annual period started. - """ + """Files processed since the start of the current annual subscription period.""" from app.models import FileRecord return _scalar_count( @@ -252,7 +369,7 @@ def get_year_file_count(db: Session, owner_id: str, period_start: datetime) -> i def _months_elapsed(period_start: datetime, now: datetime) -> int: - """Calendar months elapsed since *period_start*, clamped to 1–12.""" + """Calendar months elapsed since *period_start*, clamped to [1, 12].""" elapsed = (now.year - period_start.year) * 12 + (now.month - period_start.month) + 1 return max(1, min(elapsed, 12)) @@ -275,37 +392,33 @@ class QuotaExceeded(Exception): 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. + Skipped entirely when *owner_id* or *tier_id* is ``None`` (single-user mode). Enforcement model ----------------- - * **Announced limit** — the quota shown to users on the pricing page - (``monthly_upload_limit`` in TIERS). - * **Enforcement limit** — ``announced × settings.subscription_overage_factor`` - (default 1.33). A 150-doc/month plan is therefore enforced at 200 docs, - giving users a soft buffer before they see an error. - * **Overage flag** — if ``UserProfile.allow_overage`` is ``True`` the check - is bypassed entirely. Usage is still tracked so future billing can charge - for overages. (Not yet exposed in the admin UI.) + * **Announced limit** — the quota shown on the pricing page + (``monthly_upload_limit`` in the plan). + * **Overage buffer** — each plan stores ``overage_percent`` (default 20). + Enforcement = announced × (1 + overage_percent / 100). A 150-doc/month + plan with 20 % buffer is enforced at 180 docs. + * **Overage flag** — if ``UserProfile.allow_overage`` is ``True``, quota + checks are bypassed entirely so usage can be billed retroactively. + (Not yet exposed in the admin UI — baked in for future billing.) * **Yearly carry-over** — yearly subscribers have cumulative quota: - effective limit = ``monthly_limit × months_elapsed × overage_factor``. + effective limit = monthly_limit × months_elapsed × overage_factor. Unused quota from earlier months rolls forward automatically. - - No daily cap is enforced — ``daily_upload_limit`` in TIERS is kept as - informational data only. + * **No daily cap** — ``daily_upload_limit`` is kept for display purposes + only; it is never enforced. """ if owner_id is None or tier_id is None: return - tier = get_tier(tier_id) + tier = get_tier(tier_id, db) - # Resolve overage factor from config - from app.config import settings + # Per-plan overage_percent overrides global config default + overage_percent: int = tier.get("overage_percent", settings.subscription_overage_percent) + overage_factor: float = 1.0 + overage_percent / 100.0 - overage_factor: float = settings.subscription_overage_factor - - # Fetch profile for billing cycle and overage permission from app.models import UserProfile profile = db.query(UserProfile).filter(UserProfile.user_id == owner_id).first() @@ -314,7 +427,7 @@ def check_upload_allowed(db: Session, owner_id: str | None, tier_id: str | None) period_start: datetime | None = profile.subscription_period_start if profile else None # 1. Lifetime file cap (free tier) — always enforced regardless of overage flag - lifetime_limit = tier["lifetime_file_limit"] + lifetime_limit: int = tier["lifetime_file_limit"] if lifetime_limit > 0: enforcement_limit = int(lifetime_limit * overage_factor) count = get_lifetime_file_count(db, owner_id) @@ -327,14 +440,13 @@ def check_upload_allowed(db: Session, owner_id: str | None, tier_id: str | None) current_value=count, ) - # 2. Monthly cap — skipped entirely when overage is enabled for this user + # 2. Monthly cap — bypassed when allow_overage is True (future billing) if allow_overage: return - monthly_limit = tier["monthly_upload_limit"] + monthly_limit: int = tier["monthly_upload_limit"] if monthly_limit > 0: if billing_cycle == "yearly" and period_start is not None: - # Carry-over: cumulative usage vs cumulative budget within the subscription year now = datetime.now(timezone.utc) months = _months_elapsed(period_start, now) cumulative_budget = int(monthly_limit * months * overage_factor) @@ -342,14 +454,13 @@ def check_upload_allowed(db: Session, owner_id: str | None, tier_id: str | None) if cumulative_used >= cumulative_budget: raise QuotaExceeded( f"Annual document quota for the {tier['name']} plan has been reached. " - "Unused monthly quota carries forward — your limit will reset on your " - "annual renewal date, or you can upgrade your plan.", + "Unused monthly quota carries forward — your limit resets on your annual " + "renewal date, or you can upgrade your plan.", limit_type="monthly", limit_value=monthly_limit, current_value=cumulative_used, ) else: - # Monthly billing: check current calendar month only count = get_month_file_count(db, owner_id) enforcement_limit = int(monthly_limit * overage_factor) if count >= enforcement_limit: @@ -360,9 +471,6 @@ def check_upload_allowed(db: Session, owner_id: str | None, tier_id: str | None) limit_value=monthly_limit, current_value=count, ) - limit_value=monthly_limit, - current_value=count, - ) def get_user_tier_id(db: Session, owner_id: str | None) -> str: @@ -378,9 +486,15 @@ def get_user_tier_id(db: Session, owner_id: str | None) -> str: 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 { + """Return file counts for *owner_id*, including carry-over data for yearly plans.""" + from app.models import UserProfile + + profile = db.query(UserProfile).filter(UserProfile.user_id == owner_id).first() + result: dict[str, int] = { "lifetime": get_lifetime_file_count(db, owner_id), "today": get_today_file_count(db, owner_id), "month": get_month_file_count(db, owner_id), } + if profile and (profile.subscription_billing_cycle or "monthly") == "yearly" and profile.subscription_period_start: + result["year_to_date"] = get_year_file_count(db, owner_id, profile.subscription_period_start) + return result diff --git a/app/views/__init__.py b/app/views/__init__.py index 01cc7f91..75eafbfb 100644 --- a/app/views/__init__.py +++ b/app/views/__init__.py @@ -14,11 +14,12 @@ from app.views.general import router as general_router from app.views.google_drive import router as google_drive_router from app.views.license_routes import router as license_router # Add the license router from app.views.onedrive import router as onedrive_router +from app.views.plans import router as plans_router # Admin Plan Designer 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.subscriptions import router as subscriptions_router # Pricing + subscription pages from app.views.wizard import router as wizard_router # Create a main router that includes all the view routers @@ -37,3 +38,4 @@ router.include_router(filemanager_router) router.include_router(search_router) router.include_router(queue_router) router.include_router(subscriptions_router) # Pricing + subscription pages +router.include_router(plans_router) # Admin Plan Designer diff --git a/app/views/plans.py b/app/views/plans.py new file mode 100644 index 00000000..05c00451 --- /dev/null +++ b/app/views/plans.py @@ -0,0 +1,18 @@ +"""View route for the admin Plan Designer page.""" + +from fastapi import Request +from fastapi.responses import HTMLResponse +from fastapi.routing import APIRouter +from fastapi.templating import Jinja2Templates + +from app.auth import require_login + +router = APIRouter() +templates = Jinja2Templates(directory="frontend/templates") + + +@router.get("/admin/plans", response_class=HTMLResponse) +@require_login +async def plan_designer(request: Request) -> HTMLResponse: + """Admin Plan Designer page.""" + return templates.TemplateResponse("admin_plans.html", {"request": request}) diff --git a/app/views/subscriptions.py b/app/views/subscriptions.py index dcd644aa..f30e8837 100644 --- a/app/views/subscriptions.py +++ b/app/views/subscriptions.py @@ -18,9 +18,9 @@ router = APIRouter() @router.get("/pricing", include_in_schema=False) -async def pricing_page(request: Request): +async def pricing_page(request: Request, db: Session = Depends(get_db)): """Public-facing pricing and plans page.""" - tiers = get_all_tiers() + tiers = get_all_tiers(db) return templates.TemplateResponse( "pricing.html", { @@ -47,8 +47,8 @@ async def my_subscription_page(request: Request, db: Session = Depends(get_db)): tier_id = "business" usage = None - tier = get_tier(tier_id) - all_tiers = get_all_tiers() + tier = get_tier(tier_id, db) + all_tiers = get_all_tiers(db) return templates.TemplateResponse( "subscription.html", diff --git a/docs/SubscriptionTiers.md b/docs/SubscriptionTiers.md index 9874562f..d013fff7 100644 --- a/docs/SubscriptionTiers.md +++ b/docs/SubscriptionTiers.md @@ -1,151 +1,117 @@ # 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. +DocuElevate uses database-backed subscription plans that are fully configurable by admins via the **Plan Designer** at `/admin/plans`. Four default tiers are seeded automatically on first startup. ---- +## Default Plans -## Tier Overview +| Plan | Monthly | Yearly | Docs/Month | Lifetime Docs | OCR Pages/Mo | Max File | Mailboxes | Destinations | +|------|---------|--------|-----------|---------------|--------------|----------|-----------|--------------| +| **Free** | $0 | $0 | — | 50 total | 150 total | 5 MB | 0 | 1 | +| **Starter** | $2.99 | $28.99 | 50 | — | 300 | 25 MB | 1 | 2 | +| **Professional** | $5.99 | $57.99 | 150 | — | 750 | 100 MB | 3 | 5 | +| **Business** | $7.99 | $76.99 | 300 | — | 1,500 | Unlimited | Unlimited | 10 | -| | 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 | +> Prices ex-VAT. German customers add 19% MwSt. -\* Free tier is capped by the **lifetime** limit of 25 files; there is no -separate daily or monthly cap on top of that. +All paid plans include a **30-day free trial**. ---- +## How Plans Are Stored -## Free Tier +Plans are stored in the `subscription_plans` database table. On application startup, `seed_default_plans()` is called automatically — if the table is empty, the four built-in defaults are inserted. If plans already exist, the seed is a no-op. -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. +Users are assigned a plan via `UserProfile.subscription_tier` (stores the `plan_id` string). The subscription utility functions (`get_tier`, `get_all_tiers`) query the database first and fall back to the hard-coded `TIER_DEFAULTS` dict if the database is unavailable or the plan doesn't exist. -There is no per-day or per-month cap: the user can use all 25 files in a -single day if they wish. +## Overage Buffer ---- +### Announced vs. Enforced Limit -## Paid Tiers +DocuElevate uses a **soft-limit overage buffer** that is invisible to users: -### Starter — $9/month (or $90/year) +- The **announced limit** is what appears on the pricing page (e.g., "150 docs/month"). +- The **enforced limit** = announced × (1 + overage_percent / 100). + - With the default 20% buffer: a 150-doc plan enforces at **180 docs**. + - This prevents hard cutoffs at the exact announced limit, giving users a graceful landing. -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. +### Per-Plan vs. Global Buffer -- 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 +Each plan has its own `overage_percent` field (set in the Plan Designer). There is also a global fallback: `settings.subscription_overage_percent` (default: 20, range: 0–200), which applies when a plan does not have an explicit value. -### Professional — $29/month (or $290/year) *(Most Popular)* +Set `subscription_overage_percent=0` in your `.env` to enforce exactly at the announced limit with no buffer. -Better for growing teams that need higher volume and more integration -flexibility. +## Yearly Billing & Carry-Over -- 50 documents/day, 500 documents/month -- 10 storage destinations -- 2 500 OCR pages/month -- All processing steps, webhooks, and priority email support +When a user's `subscription_billing_cycle` is set to `yearly`: -### Business — $79/month (or $790/year) +- Unused quota from earlier months **rolls forward automatically**. +- Enforcement = `monthly_limit × months_elapsed × overage_factor` (cumulative budget from the subscription start date). +- Example: A 50-doc/month Starter plan in month 3 of its annual period has a cumulative budget of 150 docs (plus overage buffer). If the user only used 20 docs in months 1–2, they can use 130 docs in month 3. +- The `subscription_period_start` field on `UserProfile` tracks the start of the annual period. -Best for organisations that need truly unlimited throughput with dedicated -support. +## No Daily Cap -- Unlimited documents and storage destinations -- Unlimited OCR pages -- Unlimited file size -- Custom integrations and dedicated support +`daily_upload_limit` is kept for display and future reference only — it is **never enforced**. All enforcement is lifetime (free tier) or monthly/cumulative-yearly (paid tiers). -> **Contact Sales** for the Business tier — email -> `sales@docuelevate.io` with subject "Business Plan Enquiry". +## allow_overage Flag ---- +Setting `UserProfile.allow_overage = True` bypasses monthly quota checks entirely for that user. Usage is still tracked so future billing integrations can charge retroactively. This field is not yet exposed in the admin UI. -## Limit Enforcement +## Plan Designer -Limits are enforced in real-time at the upload endpoint -(`POST /api/ui-upload`). When a user exceeds any quota: +Navigate to `/admin/plans` (admin only) to: -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. +1. **View** all plans (active and inactive) with key stats. +2. **Create** a new plan with a custom `plan_id` slug. +3. **Edit** any plan's pricing, limits, overage buffer, features, and display settings. +4. **Reorder** plans using the up/down arrows (order reflects pricing page display order). +5. **Delete** a plan (does not affect existing users assigned to it). +6. **Restore Defaults** — seeds the four built-in plans (no-op if plans already exist). -Quota checks run in order: +### Overage Designer -1. Lifetime file limit (free tier only) -2. Daily file limit -3. Monthly file limit +The Plan Designer includes an overage slider (0–100%). The live preview shows: ---- +> "Announce **X** docs, enforce at **Y** docs" -## Admin Management +Overage billing and per-doc overage pricing are planned future features (currently disabled in the UI). -Administrators can view and change each user's subscription tier from the -**Admin → Users** page (`/admin/users`). +## API Endpoints -1. Click **Edit** next to any user. -2. Change the **Subscription Plan** dropdown. -3. Click **Save Changes**. +All plan endpoints are under `/api/plans/`. -The new limits take effect immediately on the user's next upload attempt. +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| `GET` | `/api/plans/` | Public | List active plans in sort order | +| `GET` | `/api/plans/admin` | Admin | List all plans including inactive | +| `POST` | `/api/plans/?plan_id=` | Admin | Create a new plan | +| `GET` | `/api/plans/{plan_id}` | Public | Get a single active plan | +| `PUT` | `/api/plans/{plan_id}` | Admin | Update an existing plan | +| `DELETE` | `/api/plans/{plan_id}` | Admin | Delete a plan | +| `POST` | `/api/plans/seed` | Admin | Seed default plans (no-op if non-empty) | +| `POST` | `/api/plans/reorder` | Admin | Update sort order; body: `{"order": ["free", "starter", ...]}` | -### API - -Admins can also manage tiers via the REST API: +### Example: List Active Plans ```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}' +curl http://localhost:8000/api/plans/ ``` ---- +### Example: Update a Plan's Monthly Limit -## 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. - ---- +```bash +curl -X PUT http://localhost:8000/api/plans/starter \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Starter", + "monthly_upload_limit": 75, + "overage_percent": 15, + ... + }' +``` ## Configuration -Subscription tiers are defined in `app/utils/subscription.py` in the `TIERS` -dictionary. Pricing, limits, and feature lists are all set there. +| Variable | Default | Description | +|----------|---------|-------------| +| `SUBSCRIPTION_OVERAGE_PERCENT` | `20` | Global overage buffer (0–200). Per-plan setting overrides this. | -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). +See `docs/ConfigurationGuide.md` for all available settings. diff --git a/frontend/templates/admin_plans.html b/frontend/templates/admin_plans.html new file mode 100644 index 00000000..b0f1b098 --- /dev/null +++ b/frontend/templates/admin_plans.html @@ -0,0 +1,608 @@ +{% extends "base.html" %} + +{% block title %}Plan Designer — DocuElevate Admin{% endblock %} + +{% block content %} +
+ + +
+
+

Plan Designer

+

Manage subscription plans shown on the public pricing page.

+
+
+ + +
+
+ + +
+

About the Overage Buffer

+

+ The overage buffer is invisible to users. We advertise X docs/month but only enforce + at X × (1 + buffer%) docs. For example, a 150-doc/month plan with a 20% buffer + enforces at 180 docs. This prevents hard cutoffs at the exact announced limit, giving users a + graceful soft landing. +

+
+ + + +
+ + +
+ + +
+ +

Loading plans…

+
+ + +
+ + + + + + + + + + + + + + + + + +
OrderPlanMonthlyYearlyMonthly LimitOverage %ActiveActions
+
+ + +
+ +
+ + + + +
+ + +{% endblock %} diff --git a/frontend/templates/admin_users.html b/frontend/templates/admin_users.html index d23b04ad..84ed2042 100644 --- a/frontend/templates/admin_users.html +++ b/frontend/templates/admin_users.html @@ -335,15 +335,46 @@ class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 bg-white" > - - - + + +

Sets the quota limits for this user. Limits are enforced on upload.

+ +
+ + +
+ + +
+ + +

+ Annual carry-over calculates from this date. Leave blank for monthly enforcement. +

+
+ @@ -439,6 +470,8 @@ function adminUsersApp() { notes: '', is_blocked: false, subscription_tier: 'free', + subscription_billing_cycle: 'monthly', + subscription_period_start: null, }, // Delete modal @@ -491,7 +524,7 @@ function adminUsersApp() { openCreateModal() { this.isCreate = true; this.modalTitle = 'Add User Profile'; - this.form = { user_id: '', display_name: '', daily_upload_limit: null, notes: '', is_blocked: false, subscription_tier: 'free' }; + this.form = { user_id: '', display_name: '', daily_upload_limit: null, notes: '', is_blocked: false, subscription_tier: 'free', subscription_billing_cycle: 'monthly', subscription_period_start: null }; this.modalOpen = true; }, @@ -506,6 +539,8 @@ function adminUsersApp() { notes: user.notes || '', is_blocked: !!user.is_blocked, subscription_tier: user.subscription_tier || 'free', + subscription_billing_cycle: user.subscription_billing_cycle || 'monthly', + subscription_period_start: user.subscription_period_start ? user.subscription_period_start.substring(0, 10) : null, }; this.modalOpen = true; }, diff --git a/frontend/templates/base.html b/frontend/templates/base.html index ad1020ad..a6db72cb 100644 --- a/frontend/templates/base.html +++ b/frontend/templates/base.html @@ -99,6 +99,9 @@
Users + + Plan Designer + Credentials @@ -186,6 +189,9 @@ Users + + Plan Designer + Credentials diff --git a/frontend/templates/pricing.html b/frontend/templates/pricing.html index 03492152..685a0047 100644 --- a/frontend/templates/pricing.html +++ b/frontend/templates/pricing.html @@ -170,19 +170,6 @@ {% endfor %} - - Files per day - {% for tier in tiers %} - - {% if tier.daily_upload_limit == 0 %} - Unlimited - {% else %} - {{ tier.daily_upload_limit }} - {% endif %} - - {% endfor %} - - Files per month {% for tier in tiers %} diff --git a/migrations/versions/015_add_subscription_plans.py b/migrations/versions/015_add_subscription_plans.py new file mode 100644 index 00000000..5f8ce557 --- /dev/null +++ b/migrations/versions/015_add_subscription_plans.py @@ -0,0 +1,70 @@ +"""Add subscription_plans table + +Revision ID: 015_add_subscription_plans +Revises: 014_add_subscription_tiers +Create Date: 2026-03-07 + +""" + +from typing import Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "015_add_subscription_plans" +down_revision: Union[str, None] = "014_add_subscription_tiers" +depends_on: Union[str, None] = None + + +def upgrade() -> None: + """Create subscription_plans table for admin-configurable plan definitions.""" + op.create_table( + "subscription_plans", + sa.Column("id", sa.Integer(), primary_key=True, index=True, nullable=False), + sa.Column("plan_id", sa.String(50), unique=True, nullable=False, index=True), + sa.Column("name", sa.String(100), nullable=False), + sa.Column("tagline", sa.String(255), nullable=True), + # Pricing + sa.Column("price_monthly", sa.Float(), nullable=False, server_default="0.0"), + sa.Column("price_yearly", sa.Float(), nullable=False, server_default="0.0"), + sa.Column("trial_days", sa.Integer(), nullable=False, server_default="0"), + # Volume limits + sa.Column("lifetime_file_limit", sa.Integer(), nullable=False, server_default="0"), + sa.Column("daily_upload_limit", sa.Integer(), nullable=False, server_default="0"), + sa.Column("monthly_upload_limit", sa.Integer(), nullable=False, server_default="0"), + sa.Column("max_storage_destinations", sa.Integer(), nullable=False, server_default="0"), + sa.Column("max_ocr_pages_monthly", sa.Integer(), nullable=False, server_default="0"), + sa.Column("max_file_size_mb", sa.Integer(), nullable=False, server_default="0"), + sa.Column("max_mailboxes", sa.Integer(), nullable=False, server_default="0"), + # Overage + sa.Column("overage_percent", sa.Integer(), nullable=False, server_default="20"), + sa.Column("allow_overage_billing", sa.Boolean(), nullable=False, server_default="0"), + sa.Column("overage_price_per_doc", sa.Float(), nullable=True), + sa.Column("overage_price_per_ocr_page", sa.Float(), nullable=True), + # Display / marketing + sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"), + sa.Column("is_highlighted", sa.Boolean(), nullable=False, server_default="0"), + sa.Column("badge_text", sa.String(50), nullable=True), + sa.Column("cta_text", sa.String(100), nullable=False, server_default="Get started"), + sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"), + sa.Column("features", sa.Text(), nullable=True), + sa.Column("api_access", sa.Boolean(), nullable=False, server_default="0"), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + ), + ) + + +def downgrade() -> None: + """Drop subscription_plans table.""" + op.drop_table("subscription_plans") diff --git a/migrations/versions/016_add_userprofile_billing.py b/migrations/versions/016_add_userprofile_billing.py new file mode 100644 index 00000000..60a8c7de --- /dev/null +++ b/migrations/versions/016_add_userprofile_billing.py @@ -0,0 +1,45 @@ +"""Add billing cycle and overage columns to user_profiles + +Revision ID: 016_add_userprofile_billing +Revises: 015_add_subscription_plans +Create Date: 2026-03-07 + +""" + +from typing import Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "016_add_userprofile_billing" +down_revision: Union[str, None] = "015_add_subscription_plans" +depends_on: Union[str, None] = None + + +def upgrade() -> None: + """Add subscription_billing_cycle, subscription_period_start, allow_overage to user_profiles.""" + op.add_column( + "user_profiles", + sa.Column( + "subscription_billing_cycle", + sa.String(10), + nullable=False, + server_default="monthly", + ), + ) + op.add_column( + "user_profiles", + sa.Column("subscription_period_start", sa.DateTime(timezone=True), nullable=True), + ) + op.add_column( + "user_profiles", + sa.Column("allow_overage", sa.Boolean(), nullable=False, server_default="0"), + ) + + +def downgrade() -> None: + """Remove billing columns from user_profiles.""" + op.drop_column("user_profiles", "allow_overage") + op.drop_column("user_profiles", "subscription_period_start") + op.drop_column("user_profiles", "subscription_billing_cycle") diff --git a/tests/test_subscription.py b/tests/test_subscription.py index d0e2fce5..2f3a66c1 100644 --- a/tests/test_subscription.py +++ b/tests/test_subscription.py @@ -1,27 +1,24 @@ """Unit tests for the subscription tier utility module.""" -import pytest -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker +from datetime import datetime, timezone from unittest.mock import MagicMock, patch -from datetime import datetime, timezone, date + +import pytest from app.utils.subscription import ( - TIERS, - TIER_ORDER, DEFAULT_TIER, - get_tier, + TIER_DEFAULTS, + TIER_ORDER, + TIERS, + QuotaExceeded, + _months_elapsed, + check_upload_allowed, get_all_tiers, + get_tier, get_user_tier_id, get_user_usage, - check_upload_allowed, - QuotaExceeded, - get_lifetime_file_count, - get_today_file_count, - get_month_file_count, ) - # --------------------------------------------------------------------------- # Basic catalogue tests # --------------------------------------------------------------------------- @@ -69,12 +66,21 @@ def test_get_all_tiers_returns_four(): @pytest.mark.unit def test_all_tiers_have_required_fields(): required = [ - "id", "name", "tagline", "price_monthly", "price_yearly", + "id", + "name", + "tagline", + "price_monthly", + "price_yearly", "trial_days", - "lifetime_file_limit", "daily_upload_limit", "monthly_upload_limit", - "max_storage_destinations", "max_ocr_pages_monthly", "max_file_size_mb", + "lifetime_file_limit", + "daily_upload_limit", + "monthly_upload_limit", + "max_storage_destinations", + "max_ocr_pages_monthly", + "max_file_size_mb", "max_mailboxes", - "features", "cta", + "features", + "cta", ] for tid, tier in TIERS.items(): for field in required: @@ -103,18 +109,21 @@ def test_free_tier_has_no_mailboxes(): def test_business_tier_has_highest_limits(): """Business tier must have the highest limits of all paid tiers.""" t = TIERS["business"] - # lifetime, daily, monthly: no hard cap (0 = unlimited) for lifetime; daily/monthly capped + # lifetime: no hard cap (0 = unlimited) assert t["lifetime_file_limit"] == 0 - assert t["daily_upload_limit"] == 30 + # no daily cap (0 = unlimited) + assert t["daily_upload_limit"] == 0 assert t["monthly_upload_limit"] == 300 assert t["max_ocr_pages_monthly"] == 1500 - # unlimited mailboxes + # unlimited mailboxes (0 = unlimited) assert t["max_mailboxes"] == 0 + # unlimited file size (0 = unlimited) + assert t["max_file_size_mb"] == 0 @pytest.mark.unit def test_mailbox_limits_increase_by_tier(): - """Mailbox limits must increase across tiers: free=0, starter=1, professional=3, business=0(∞).""" + """Mailbox limits must increase across tiers: free=0, starter=1, professional=3, business=0(inf).""" assert TIERS["free"]["max_mailboxes"] == 0 assert TIERS["starter"]["max_mailboxes"] == 1 assert TIERS["professional"]["max_mailboxes"] == 3 @@ -197,10 +206,16 @@ def test_check_upload_skipped_without_tier(): @pytest.mark.unit def test_check_upload_raises_when_lifetime_exceeded(): - """Free tier: should raise QuotaExceeded when lifetime limit (50) is hit.""" + """Free tier: raise QuotaExceeded at lifetime limit (50) with 0% buffer (exact enforcement).""" db = MagicMock() + # Return None for both SubscriptionPlan lookup and UserProfile lookup + db.query.return_value.filter.return_value.first.return_value = None - with patch("app.utils.subscription.get_lifetime_file_count", return_value=50): + with ( + patch("app.utils.subscription.settings") as mock_settings, + patch("app.utils.subscription.get_lifetime_file_count", return_value=50), + ): + mock_settings.subscription_overage_percent = 0 with pytest.raises(QuotaExceeded) as exc_info: check_upload_allowed(db, "user@example.com", "free") @@ -212,31 +227,34 @@ def test_check_upload_raises_when_lifetime_exceeded(): @pytest.mark.unit def test_check_upload_passes_below_lifetime_limit(): db = MagicMock() - with patch("app.utils.subscription.get_lifetime_file_count", return_value=10): + db.query.return_value.filter.return_value.first.return_value = None + + with ( + patch("app.utils.subscription.settings") as mock_settings, + patch("app.utils.subscription.get_lifetime_file_count", return_value=10), + ): + mock_settings.subscription_overage_percent = 0 check_upload_allowed(db, "user@example.com", "free") # must not raise -@pytest.mark.unit -def test_check_upload_raises_when_daily_exceeded(): - """Starter tier: should raise QuotaExceeded when daily limit (5) is hit.""" - db = MagicMock() - - with patch("app.utils.subscription.get_lifetime_file_count", return_value=0), \ - patch("app.utils.subscription.get_today_file_count", return_value=5): - with pytest.raises(QuotaExceeded) as exc_info: - check_upload_allowed(db, "user@example.com", "starter") - - assert exc_info.value.limit_type == "daily" - - @pytest.mark.unit def test_check_upload_raises_when_monthly_exceeded(): - """Starter tier: should raise QuotaExceeded when monthly limit (50) is hit.""" + """Starter tier: raise QuotaExceeded when monthly limit (50) is hit (0% buffer).""" db = MagicMock() - with patch("app.utils.subscription.get_lifetime_file_count", return_value=0), \ - patch("app.utils.subscription.get_today_file_count", return_value=0), \ - patch("app.utils.subscription.get_month_file_count", return_value=50): + # UserProfile mock: no overage, monthly billing, no period_start + profile_mock = MagicMock() + profile_mock.allow_overage = False + profile_mock.subscription_billing_cycle = "monthly" + profile_mock.subscription_period_start = None + db.query.return_value.filter.return_value.first.side_effect = [None, profile_mock] + + with ( + patch("app.utils.subscription.settings") as mock_settings, + patch("app.utils.subscription.get_lifetime_file_count", return_value=0), + patch("app.utils.subscription.get_month_file_count", return_value=50), + ): + mock_settings.subscription_overage_percent = 0 with pytest.raises(QuotaExceeded) as exc_info: check_upload_allowed(db, "user@example.com", "starter") @@ -245,27 +263,154 @@ def test_check_upload_raises_when_monthly_exceeded(): @pytest.mark.unit def test_check_upload_business_tier_within_limits(): - """Business tier: upload is allowed as long as counts are below the capped limits.""" + """Business tier: upload is allowed when count is below the monthly limit.""" db = MagicMock() - # Use counts well below Business limits (30/day, 300/mo) - with patch("app.utils.subscription.get_lifetime_file_count", return_value=0), \ - patch("app.utils.subscription.get_today_file_count", return_value=10), \ - patch("app.utils.subscription.get_month_file_count", return_value=100): + profile_mock = MagicMock() + profile_mock.allow_overage = False + profile_mock.subscription_billing_cycle = "monthly" + profile_mock.subscription_period_start = None + db.query.return_value.filter.return_value.first.side_effect = [None, profile_mock] + + with ( + patch("app.utils.subscription.settings") as mock_settings, + patch("app.utils.subscription.get_lifetime_file_count", return_value=0), + patch("app.utils.subscription.get_month_file_count", return_value=100), + ): + mock_settings.subscription_overage_percent = 0 check_upload_allowed(db, "user@example.com", "business") # must not raise +# --------------------------------------------------------------------------- +# Overage buffer tests +# --------------------------------------------------------------------------- + + @pytest.mark.unit -def test_check_upload_business_tier_raises_when_daily_exceeded(): - """Business tier: should raise QuotaExceeded when daily limit (30) is hit.""" +def test_overage_percent_allows_buffer(): + """Starter monthly=50, 20% buffer -> enforce at 60. count=55 should pass, count=61 should raise.""" + profile_mock = MagicMock() + profile_mock.allow_overage = False + profile_mock.subscription_billing_cycle = "monthly" + profile_mock.subscription_period_start = None + db = MagicMock() + db.query.return_value.filter.return_value.first.side_effect = [None, profile_mock] - with patch("app.utils.subscription.get_lifetime_file_count", return_value=0), \ - patch("app.utils.subscription.get_today_file_count", return_value=30): + with ( + patch("app.utils.subscription.settings") as mock_settings, + patch("app.utils.subscription.get_lifetime_file_count", return_value=0), + patch("app.utils.subscription.get_month_file_count", return_value=55), + ): + mock_settings.subscription_overage_percent = 20 + # count=55 < 60 (50*1.20) -> should NOT raise + check_upload_allowed(db, "user@example.com", "starter") + + # Reset mock for second call + db2 = MagicMock() + db2.query.return_value.filter.return_value.first.side_effect = [None, profile_mock] + + with ( + patch("app.utils.subscription.settings") as mock_settings, + patch("app.utils.subscription.get_lifetime_file_count", return_value=0), + patch("app.utils.subscription.get_month_file_count", return_value=61), + ): + mock_settings.subscription_overage_percent = 20 + # count=61 >= 60 -> should raise with pytest.raises(QuotaExceeded) as exc_info: - check_upload_allowed(db, "user@example.com", "business") + check_upload_allowed(db2, "user@example.com", "starter") + assert exc_info.value.limit_type == "monthly" + assert exc_info.value.limit_value == 50 - assert exc_info.value.limit_type == "daily" - assert exc_info.value.limit_value == 30 + +@pytest.mark.unit +def test_allow_overage_flag_bypasses_monthly_limit(): + """When allow_overage=True on UserProfile, monthly cap is never enforced.""" + db = MagicMock() + profile_mock = MagicMock() + profile_mock.allow_overage = True + profile_mock.subscription_billing_cycle = "monthly" + profile_mock.subscription_period_start = None + db.query.return_value.filter.return_value.first.side_effect = [None, profile_mock] + + with ( + patch("app.utils.subscription.settings") as mock_settings, + patch("app.utils.subscription.get_lifetime_file_count", return_value=0), + patch("app.utils.subscription.get_month_file_count", return_value=999999), + ): + mock_settings.subscription_overage_percent = 0 + # Should NOT raise even with enormous count + check_upload_allowed(db, "user@example.com", "starter") + + +@pytest.mark.unit +def test_yearly_carryover_allows_accumulated_budget(): + """Yearly billing carry-over: period_start 2 months ago, monthly=50 (0% buffer). + Budget = 50 * months_elapsed. used=80 should pass; used at budget+1 should raise. + """ + db = MagicMock() + profile_mock = MagicMock() + profile_mock.allow_overage = False + profile_mock.subscription_billing_cycle = "yearly" + now = datetime.now(timezone.utc) + # period_start is 2 months before current month + if now.month > 2: + period_start = now.replace(month=now.month - 2, day=1) + else: + period_start = now.replace(year=now.year - 1, month=now.month + 10, day=1) + profile_mock.subscription_period_start = period_start + db.query.return_value.filter.return_value.first.side_effect = [None, profile_mock] + + # months_elapsed with period 2 months ago = 3 (prev-prev, prev, current) + # budget = 50 * 3 = 150 with 0% buffer + with ( + patch("app.utils.subscription.settings") as mock_settings, + patch("app.utils.subscription.get_lifetime_file_count", return_value=0), + patch("app.utils.subscription.get_year_file_count", return_value=80), + ): + mock_settings.subscription_overage_percent = 0 + # 80 < 150 -> should NOT raise + check_upload_allowed(db, "user@example.com", "starter") + + # Reset mock for second call + db2 = MagicMock() + db2.query.return_value.filter.return_value.first.side_effect = [None, profile_mock] + + with ( + patch("app.utils.subscription.settings") as mock_settings, + patch("app.utils.subscription.get_lifetime_file_count", return_value=0), + patch("app.utils.subscription.get_year_file_count", return_value=151), + ): + mock_settings.subscription_overage_percent = 0 + # 151 >= 150 -> should raise + with pytest.raises(QuotaExceeded) as exc_info: + check_upload_allowed(db2, "user@example.com", "starter") + assert exc_info.value.limit_type == "monthly" + + +# --------------------------------------------------------------------------- +# _months_elapsed helper +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_months_elapsed_same_month(): + now = datetime(2025, 6, 15, tzinfo=timezone.utc) + start = datetime(2025, 6, 1, tzinfo=timezone.utc) + assert _months_elapsed(start, now) == 1 + + +@pytest.mark.unit +def test_months_elapsed_two_months(): + now = datetime(2025, 8, 1, tzinfo=timezone.utc) + start = datetime(2025, 6, 1, tzinfo=timezone.utc) + assert _months_elapsed(start, now) == 3 # June, July, August = 3 + + +@pytest.mark.unit +def test_months_elapsed_clamped_to_12(): + now = datetime(2026, 6, 1, tzinfo=timezone.utc) + start = datetime(2024, 1, 1, tzinfo=timezone.utc) + assert _months_elapsed(start, now) == 12 # --------------------------------------------------------------------------- @@ -276,14 +421,52 @@ def test_check_upload_business_tier_raises_when_daily_exceeded(): @pytest.mark.unit def test_get_user_usage_returns_dict_with_correct_keys(): db = MagicMock() - with patch("app.utils.subscription.get_lifetime_file_count", return_value=10), \ - patch("app.utils.subscription.get_today_file_count", return_value=2), \ - patch("app.utils.subscription.get_month_file_count", return_value=8): + # No profile -> monthly billing, no period_start + db.query.return_value.filter.return_value.first.return_value = None + + with ( + patch("app.utils.subscription.get_lifetime_file_count", return_value=10), + patch("app.utils.subscription.get_today_file_count", return_value=2), + patch("app.utils.subscription.get_month_file_count", return_value=8), + ): result = get_user_usage(db, "user@example.com") assert result == {"lifetime": 10, "today": 2, "month": 8} +@pytest.mark.unit +def test_get_user_usage_includes_year_to_date_for_yearly(): + """Yearly subscriber gets year_to_date key in usage dict.""" + db = MagicMock() + profile_mock = MagicMock() + profile_mock.subscription_billing_cycle = "yearly" + period_start = datetime(2025, 1, 1, tzinfo=timezone.utc) + profile_mock.subscription_period_start = period_start + db.query.return_value.filter.return_value.first.return_value = profile_mock + + with ( + patch("app.utils.subscription.get_lifetime_file_count", return_value=10), + patch("app.utils.subscription.get_today_file_count", return_value=2), + patch("app.utils.subscription.get_month_file_count", return_value=8), + patch("app.utils.subscription.get_year_file_count", return_value=40), + ): + result = get_user_usage(db, "user@example.com") + + assert "year_to_date" in result + assert result["year_to_date"] == 40 + + +# --------------------------------------------------------------------------- +# TIERS / TIER_DEFAULTS alias +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_tiers_is_alias_for_tier_defaults(): + """TIERS must be the same object as TIER_DEFAULTS (backward compat alias).""" + assert TIERS is TIER_DEFAULTS + + # --------------------------------------------------------------------------- # API: /api/subscriptions/tiers (integration-style, mocked app) # --------------------------------------------------------------------------- From ab532b55dcdf5bface2ac617db034c3d96b6a3ab Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 21:09:44 +0000 Subject: [PATCH 7/9] fix: resolve mypy and djlint CI failures - app/utils/subscription.py: add Any type annotation to _scalar_count() query parameter (mypy no-untyped-def error at line 316) - frontend/templates/admin_plans.html: remove empty
at line 344 (djlint H020 empty tag pair error) Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- app/utils/subscription.py | 2 +- frontend/templates/admin_plans.html | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/app/utils/subscription.py b/app/utils/subscription.py index d7dd92c0..6f7ea841 100644 --- a/app/utils/subscription.py +++ b/app/utils/subscription.py @@ -313,7 +313,7 @@ def _today_utc() -> date: return datetime.now(timezone.utc).date() -def _scalar_count(query) -> int: +def _scalar_count(query: Any) -> int: """Execute a count query and return an int, defaulting to 0 for NULL.""" return query.scalar() or 0 diff --git a/frontend/templates/admin_plans.html b/frontend/templates/admin_plans.html index b0f1b098..e4e4cc6b 100644 --- a/frontend/templates/admin_plans.html +++ b/frontend/templates/admin_plans.html @@ -341,7 +341,6 @@ Coming soon -
Date: Fri, 6 Mar 2026 21:22:46 +0000 Subject: [PATCH 8/9] fix(api): move quota check before file write in ui-upload endpoint Subscription quota is now checked before the file is written to disk, so users who have exceeded their quota do not waste bandwidth or disk I/O. The post-write cleanup path for quota rejections is no longer needed and has been removed. Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- app/api/files.py | 37 +++++++++++++++++-------------------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/app/api/files.py b/app/api/files.py index 35691a7b..87586664 100644 --- a/app/api/files.py +++ b/app/api/files.py @@ -1256,6 +1256,23 @@ async def ui_upload(request: Request, db: DbSession, file: UploadFile = File(... # Store both the safe original name and the unique name target_path = os.path.join(workdir, target_filename) + # 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) BEFORE writing the file + # so that users who have exceeded their quota do not waste bandwidth or disk I/O. + 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: + raise HTTPException( + status_code=status.HTTP_402_PAYMENT_REQUIRED, + detail=str(qe), + ) + # Read file in chunks to avoid loading the entire body into memory at once, # enforcing the size limit during the read so memory usage stays bounded. try: @@ -1292,26 +1309,6 @@ async def ui_upload(request: Request, db: DbSession, file: UploadFile = File(... mime_type, _ = mimetypes.guess_type(target_path) file_ext = os.path.splitext(target_path)[1].lower() - # 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 temporarily written file before returning the error - # to avoid consuming disk space for a rejected upload. - 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" From 9853a27d82a5c68ca01b9350365589837b0f8911 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 21:29:44 +0000 Subject: [PATCH 9/9] fix: add subscription_overage_percent to SETTING_METADATA and docs Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- .env.demo | 6 ++++++ app/utils/settings_service.py | 13 +++++++++++++ docs/ConfigurationGuide.md | 10 ++++++++++ 3 files changed, 29 insertions(+) diff --git a/.env.demo b/.env.demo index a5bb7f16..7fe4cd67 100644 --- a/.env.demo +++ b/.env.demo @@ -141,6 +141,12 @@ UNOWNED_DOCS_VISIBLE_TO_ALL=true # Leave empty/unset to keep them unowned until claimed. # DEFAULT_OWNER_ID= +# **Subscription / Quota Settings** +# Soft-limit overage buffer in percent (0–200). Announced quota is multiplied by (1 + percent/100) +# for actual enforcement. E.g. 20 means a 150-doc/month plan enforces at 180. 0 = enforce exactly. +# Per-plan overage_percent set in the Plan Designer overrides this global default. +# SUBSCRIPTION_OVERAGE_PERCENT=20 + # **OpenID Connect/Authentik Settings** AUTHENTIK_CLIENT_ID= AUTHENTIK_CLIENT_SECRET= diff --git a/app/utils/settings_service.py b/app/utils/settings_service.py index 9c79e2d8..c11e64d6 100644 --- a/app/utils/settings_service.py +++ b/app/utils/settings_service.py @@ -1771,6 +1771,19 @@ SETTING_METADATA = { "required": False, "restart_required": True, }, + "subscription_overage_percent": { + "category": "Subscriptions", + "description": ( + "Soft-limit overage buffer in percent (0–200). The announced monthly quota is increased by this " + "percentage for actual enforcement. For example, 20 means a 150-doc/month plan enforces at 180 docs " + "(150 × 1.20). Set to 0 to enforce exactly at the announced limit. Per-plan overage_percent configured " + "in the Plan Designer overrides this global default. Default: 20." + ), + "type": "integer", + "sensitive": False, + "required": False, + "restart_required": False, + }, } diff --git a/docs/ConfigurationGuide.md b/docs/ConfigurationGuide.md index 5c4dc3a0..863afb09 100644 --- a/docs/ConfigurationGuide.md +++ b/docs/ConfigurationGuide.md @@ -195,6 +195,16 @@ Admins can assign ownership of documents to any user: The `DEFAULT_OWNER_ID` setting can also be configured via the Settings page, which provides an autocomplete field that searches existing users by substring. +### Subscriptions & Upload Quotas + +DocuElevate supports configurable subscription plans with per-user upload quotas enforced at upload time. +Plans are managed via the **Plan Designer** at `/admin/plans`. The following global setting controls the +default overage buffer applied across all plans. + +| **Variable** | **Description** | **Default** | +|----------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------| +| `SUBSCRIPTION_OVERAGE_PERCENT` | Soft-limit overage buffer in percent (0–200). The announced monthly quota is multiplied by `(1 + percent/100)` for actual enforcement. E.g. `20` means a 150-doc/month plan enforces at 180 docs (150 × 1.20). Set `0` to enforce exactly at the announced limit. Per-plan `overage_percent` configured in the Plan Designer overrides this global default. | `20` | + ### Security Headers DocuElevate supports HTTP security headers to improve browser-side security. **These headers are disabled by default** since most deployments use a reverse proxy (Traefik, Nginx, etc.) that already adds them. Enable only if deploying directly without a reverse proxy. See [Deployment Guide - Security Headers](DeploymentGuide.md#security-headers) for detailed configuration examples.