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

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

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-06 15:53:12 +00:00
parent 70dd35dec4
commit 179f6125e8
18 changed files with 2014 additions and 18 deletions
+2
View File
@@ -23,6 +23,7 @@ from app.api.saved_searches import router as saved_searches_router
from app.api.search import router as search_router
from app.api.settings import router as settings_router
from app.api.similarity import router as similarity_router
from app.api.subscriptions import router as subscriptions_router
from app.api.url_upload import router as url_upload_router
# Import all the individual routers
@@ -56,3 +57,4 @@ router.include_router(similarity_router)
router.include_router(duplicates_router)
router.include_router(webhooks_router)
router.include_router(database_router)
router.include_router(subscriptions_router)
+18
View File
@@ -51,6 +51,10 @@ class UserProfileUpsert(BaseModel):
)
notes: str | None = Field(default=None, max_length=4096, description="Admin notes about this user")
is_blocked: bool = Field(default=False, description="Block this user from uploading")
subscription_tier: str | None = Field(
default="free",
description="Subscription tier: free | starter | professional | business",
)
class UserProfileResponse(BaseModel):
@@ -62,6 +66,7 @@ class UserProfileResponse(BaseModel):
daily_upload_limit: int | None
notes: str | None
is_blocked: bool
subscription_tier: str | None
created_at: str | None
updated_at: str | None
@@ -76,6 +81,7 @@ class UserSummary(BaseModel):
daily_upload_limit: int | None
notes: str | None
is_blocked: bool
subscription_tier: str | None
profile_id: int | None
document_count: int
last_upload: str | None
@@ -99,6 +105,7 @@ def _profile_to_dict(profile: UserProfile) -> dict[str, Any]:
"daily_upload_limit": profile.daily_upload_limit,
"notes": profile.notes,
"is_blocked": profile.is_blocked,
"subscription_tier": profile.subscription_tier or "free",
"created_at": profile.created_at.isoformat() if profile.created_at else None,
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
}
@@ -164,6 +171,7 @@ def list_users(
"daily_upload_limit": profile.daily_upload_limit if profile else None,
"notes": profile.notes if profile else None,
"is_blocked": profile.is_blocked if profile else False,
"subscription_tier": (profile.subscription_tier or "free") if profile else "free",
"profile_id": profile.id if profile else None,
"document_count": doc_row.doc_count if doc_row else 0,
"last_upload": doc_row.last_upload.isoformat() if (doc_row and doc_row.last_upload) else None,
@@ -199,6 +207,7 @@ def get_user(user_id: str, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
"daily_upload_limit": profile.daily_upload_limit if profile else None,
"notes": profile.notes if profile else None,
"is_blocked": profile.is_blocked if profile else False,
"subscription_tier": (profile.subscription_tier or "free") if profile else "free",
"profile_id": profile.id if profile else None,
"document_count": doc_count,
"last_upload": last_upload,
@@ -226,6 +235,15 @@ def upsert_user_profile(
profile.daily_upload_limit = body.daily_upload_limit
profile.notes = body.notes
profile.is_blocked = body.is_blocked
if body.subscription_tier is not None:
from app.utils.subscription import TIERS
if body.subscription_tier not in TIERS:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid subscription_tier '{body.subscription_tier}'. Valid values: {list(TIERS.keys())}",
)
profile.subscription_tier = body.subscription_tier
try:
db.commit()
+17 -1
View File
@@ -11,7 +11,7 @@ import zipfile
from datetime import datetime, timezone
from typing import Annotated, List, Optional
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile, status
from fastapi.responses import StreamingResponse
from sqlalchemy import asc, desc
from sqlalchemy.orm import Session
@@ -1295,6 +1295,22 @@ async def ui_upload(request: Request, db: DbSession, file: UploadFile = File(...
# Determine the owner_id for multi-user document isolation
upload_owner_id = get_current_owner_id(request) if settings.multi_user_enabled else None
# Enforce subscription tier upload quotas (multi-user mode only)
if settings.multi_user_enabled and upload_owner_id:
from app.utils.subscription import QuotaExceeded, check_upload_allowed, get_user_tier_id
tier_id = get_user_tier_id(db, upload_owner_id)
try:
check_upload_allowed(db, upload_owner_id, tier_id)
except QuotaExceeded as qe:
# Clean up the already-saved file before rejecting
if os.path.exists(target_path):
os.remove(target_path)
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail=str(qe),
)
# Check if it's a PDF by extension or MIME type
is_pdf = file_ext == ".pdf" or mime_type == "application/pdf"
+153
View File
@@ -0,0 +1,153 @@
"""API endpoints for subscription tiers and usage statistics.
Public endpoints:
GET /api/subscriptions/tiers — list all available plans
GET /api/subscriptions/my — current user's plan + usage (auth required)
GET /api/subscriptions/platform — platform-wide stats (admin only)
"""
import logging
from datetime import datetime, timezone
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.database import get_db
from app.utils.subscription import (
TIER_ORDER,
TIERS,
get_all_tiers,
get_tier,
get_user_tier_id,
get_user_usage,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/subscriptions", tags=["subscriptions"])
DbSession = Annotated[Session, Depends(get_db)]
# ---------------------------------------------------------------------------
# Auth helpers
# ---------------------------------------------------------------------------
def _get_current_user(request: Request) -> dict | None:
return request.session.get("user")
def _require_admin(request: Request) -> dict:
user = request.session.get("user")
if not user or not user.get("is_admin"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return user
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("/tiers", summary="List all subscription tiers")
def list_tiers() -> dict[str, Any]:
"""Return the full list of subscription plans in display order."""
return {
"tiers": get_all_tiers(),
"order": TIER_ORDER,
"default": "free",
}
@router.get("/my", summary="Get current user's subscription and usage")
def my_subscription(request: Request, db: DbSession) -> dict[str, Any]:
"""Return the authenticated user's subscription tier and current usage counts."""
from app.config import settings
user = _get_current_user(request)
if not settings.multi_user_enabled:
# In single-user mode there is no concept of a subscription plan
return {
"multi_user_mode": False,
"tier": TIERS["business"], # unrestricted
"usage": None,
}
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
owner_id: str = user.get("username") or user.get("email") or user.get("sub") or ""
tier_id = get_user_tier_id(db, owner_id)
tier = get_tier(tier_id)
usage = get_user_usage(db, owner_id)
return {
"multi_user_mode": True,
"owner_id": owner_id,
"tier": tier,
"usage": usage,
}
@router.get("/platform", summary="Platform-wide usage statistics (admin only)")
def platform_stats(request: Request, db: DbSession) -> dict[str, Any]:
"""Return aggregate statistics across all users and tiers (admin only)."""
_require_admin(request)
from app.models import FileRecord, UserProfile
today = datetime.now(timezone.utc).date()
# Total files
total_files: int = db.query(func.count(FileRecord.id)).scalar() or 0
# Files today
files_today: int = (
db.query(func.count(FileRecord.id)).filter(func.date(FileRecord.created_at) == today).scalar() or 0
)
# Files this month
files_this_month: int = (
db.query(func.count(FileRecord.id))
.filter(func.strftime("%Y-%m", FileRecord.created_at) == today.strftime("%Y-%m"))
.scalar()
or 0
)
# Files with OCR text (proxy for pages OCRed — approximation)
files_with_ocr: int = db.query(func.count(FileRecord.id)).filter(FileRecord.ocr_text.isnot(None)).scalar() or 0
# Unique active users (ever uploaded)
unique_users: int = (
db.query(func.count(func.distinct(FileRecord.owner_id))).filter(FileRecord.owner_id.isnot(None)).scalar() or 0
)
# Users per subscription tier
profiles = (
db.query(UserProfile.subscription_tier, func.count(UserProfile.id))
.group_by(UserProfile.subscription_tier)
.all()
)
tier_distribution: dict[str, int] = {row[0] or "free": row[1] for row in profiles}
# Fill in zeros for tiers with no users
for tid in TIER_ORDER:
tier_distribution.setdefault(tid, 0)
return {
"files": {
"total": total_files,
"today": files_today,
"this_month": files_this_month,
"with_ocr": files_with_ocr,
},
"users": {
"unique_uploaders": unique_users,
"tier_distribution": tier_distribution,
},
"generated_at": datetime.now(timezone.utc).isoformat(),
}
+4
View File
@@ -200,5 +200,9 @@ class UserProfile(Base):
# When True the user is prevented from uploading new documents
is_blocked = Column(Boolean, default=False, nullable=False)
# Subscription tier: "free" | "starter" | "professional" | "business"
# NULL is treated as "free" by the subscription utility.
subscription_tier = Column(String(50), nullable=True, default="free")
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
+303
View File
@@ -0,0 +1,303 @@
"""
Subscription tier definitions and enforcement utilities for DocuElevate SaaS.
Four tiers:
- free $0/mo — 25 lifetime files, 1 destination, 50 OCR pages/mo
- starter $9/mo — 10/day, 100/mo, 3 destinations, 500 OCR pages/mo
- professional $29/mo — 50/day, 500/mo, 10 destinations, 2 500 OCR pages/mo
- business $79/mo — unlimited, unlimited destinations, unlimited OCR
Limits use 0 to represent "unlimited".
"""
from __future__ import annotations
import logging
from datetime import date, datetime, timezone
from typing import Any
from sqlalchemy import func
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Tier catalogue
# ---------------------------------------------------------------------------
TIERS: dict[str, dict[str, Any]] = {
"free": {
"id": "free",
"name": "Free",
"tagline": "Explore DocuElevate at no cost",
"price_monthly": 0,
"price_yearly": 0,
"highlight": False,
# Hard caps — 0 = unlimited
"lifetime_file_limit": 25, # total files ever processed
"daily_upload_limit": 0, # no per-day cap (capped by lifetime)
"monthly_upload_limit": 0, # no per-month cap (capped by lifetime)
"max_storage_destinations": 1,
"max_ocr_pages_monthly": 50,
"max_file_size_mb": 10,
"api_access": False,
# Marketing feature list (shown on pricing page)
"features": [
"25 documents lifetime total",
"1 storage destination",
"50 OCR pages / month",
"10 MB max file size",
"Basic AI metadata extraction",
"Community support",
],
"cta": "Get started free",
"badge": None,
},
"starter": {
"id": "starter",
"name": "Starter",
"tagline": "Perfect for individuals & small teams",
"price_monthly": 9,
"price_yearly": 90,
"highlight": False,
"lifetime_file_limit": 0,
"daily_upload_limit": 10,
"monthly_upload_limit": 100,
"max_storage_destinations": 3,
"max_ocr_pages_monthly": 500,
"max_file_size_mb": 50,
"api_access": True,
"features": [
"10 documents / day",
"100 documents / month",
"3 storage destinations",
"500 OCR pages / month",
"50 MB max file size",
"Full AI metadata extraction",
"Email ingestion",
"API access",
"Email support",
],
"cta": "Start with Starter",
"badge": None,
},
"professional": {
"id": "professional",
"name": "Professional",
"tagline": "For growing teams that need more power",
"price_monthly": 29,
"price_yearly": 290,
"highlight": True, # shown as "Most popular"
"lifetime_file_limit": 0,
"daily_upload_limit": 50,
"monthly_upload_limit": 500,
"max_storage_destinations": 10,
"max_ocr_pages_monthly": 2500,
"max_file_size_mb": 200,
"api_access": True,
"features": [
"50 documents / day",
"500 documents / month",
"10 storage destinations",
"2 500 OCR pages / month",
"200 MB max file size",
"Advanced AI workflows",
"Email & URL ingestion",
"Webhooks",
"Priority email support",
],
"cta": "Go Professional",
"badge": "Most Popular",
},
"business": {
"id": "business",
"name": "Business",
"tagline": "Unlimited processing for organisations",
"price_monthly": 79,
"price_yearly": 790,
"highlight": False,
"lifetime_file_limit": 0,
"daily_upload_limit": 0,
"monthly_upload_limit": 0,
"max_storage_destinations": 0,
"max_ocr_pages_monthly": 0,
"max_file_size_mb": 0,
"api_access": True,
"features": [
"Unlimited documents",
"Unlimited storage destinations",
"Unlimited OCR pages",
"Unlimited file size",
"All AI processing steps",
"All ingestion methods",
"Webhooks & full API access",
"Custom integrations",
"Dedicated support",
],
"cta": "Contact Sales",
"badge": "Best Value",
},
}
# Display order for the pricing page
TIER_ORDER = ["free", "starter", "professional", "business"]
# Default tier assigned to new users
DEFAULT_TIER = "free"
# ---------------------------------------------------------------------------
# Getters
# ---------------------------------------------------------------------------
def get_tier(tier_id: str) -> dict[str, Any]:
"""Return tier config dict; falls back to *free* for unknown ids."""
return TIERS.get(tier_id, TIERS["free"])
def get_all_tiers() -> list[dict[str, Any]]:
"""Return tiers in display order."""
return [TIERS[tid] for tid in TIER_ORDER]
# ---------------------------------------------------------------------------
# Usage queries
# ---------------------------------------------------------------------------
def _today_utc() -> date:
return datetime.now(timezone.utc).date()
def get_lifetime_file_count(db: Session, owner_id: str) -> int:
"""Total files ever processed by this user (not counting duplicates)."""
from app.models import FileRecord
return (
db.query(func.count(FileRecord.id))
.filter(FileRecord.owner_id == owner_id, FileRecord.is_duplicate.is_(False))
.scalar()
or 0
)
def get_today_file_count(db: Session, owner_id: str) -> int:
"""Files processed by this user today (UTC, not counting duplicates)."""
from app.models import FileRecord
today = _today_utc()
return (
db.query(func.count(FileRecord.id))
.filter(
FileRecord.owner_id == owner_id,
FileRecord.is_duplicate.is_(False),
func.date(FileRecord.created_at) == today,
)
.scalar()
or 0
)
def get_month_file_count(db: Session, owner_id: str) -> int:
"""Files processed by this user this calendar month (UTC, not counting duplicates)."""
from app.models import FileRecord
today = _today_utc()
return (
db.query(func.count(FileRecord.id))
.filter(
FileRecord.owner_id == owner_id,
FileRecord.is_duplicate.is_(False),
func.strftime("%Y-%m", FileRecord.created_at) == today.strftime("%Y-%m"),
)
.scalar()
or 0
)
# ---------------------------------------------------------------------------
# Limit enforcement
# ---------------------------------------------------------------------------
class QuotaExceeded(Exception):
"""Raised when a user has hit a subscription limit."""
def __init__(self, message: str, limit_type: str, limit_value: int, current_value: int) -> None:
super().__init__(message)
self.limit_type = limit_type
self.limit_value = limit_value
self.current_value = current_value
def check_upload_allowed(db: Session, owner_id: str | None, tier_id: str | None) -> None:
"""Raise :class:`QuotaExceeded` if this user is not allowed to upload another file.
When *owner_id* or *tier_id* is ``None`` (e.g. single-user mode) the check
is skipped entirely.
"""
if owner_id is None or tier_id is None:
return
tier = get_tier(tier_id)
# 1. Lifetime file cap (free tier)
lifetime_limit = tier["lifetime_file_limit"]
if lifetime_limit > 0:
count = get_lifetime_file_count(db, owner_id)
if count >= lifetime_limit:
raise QuotaExceeded(
f"Lifetime file limit of {lifetime_limit} reached for the {tier['name']} plan. "
"Please upgrade to continue processing documents.",
limit_type="lifetime",
limit_value=lifetime_limit,
current_value=count,
)
# 2. Daily cap
daily_limit = tier["daily_upload_limit"]
if daily_limit > 0:
count = get_today_file_count(db, owner_id)
if count >= daily_limit:
raise QuotaExceeded(
f"Daily file limit of {daily_limit} reached for the {tier['name']} plan. "
"Please try again tomorrow or upgrade your plan.",
limit_type="daily",
limit_value=daily_limit,
current_value=count,
)
# 3. Monthly cap
monthly_limit = tier["monthly_upload_limit"]
if monthly_limit > 0:
count = get_month_file_count(db, owner_id)
if count >= monthly_limit:
raise QuotaExceeded(
f"Monthly file limit of {monthly_limit} reached for the {tier['name']} plan. "
"Please upgrade your plan for more documents this month.",
limit_type="monthly",
limit_value=monthly_limit,
current_value=count,
)
def get_user_tier_id(db: Session, owner_id: str | None) -> str:
"""Return the subscription tier id for *owner_id*, defaulting to 'free'."""
if owner_id is None:
return DEFAULT_TIER
from app.models import UserProfile
profile = db.query(UserProfile).filter(UserProfile.user_id == owner_id).first()
if profile and profile.subscription_tier:
return profile.subscription_tier
return DEFAULT_TIER
def get_user_usage(db: Session, owner_id: str) -> dict[str, int]:
"""Return a dict with lifetime / daily / monthly file counts for *owner_id*."""
return {
"lifetime": get_lifetime_file_count(db, owner_id),
"today": get_today_file_count(db, owner_id),
"month": get_month_file_count(db, owner_id),
}
+2
View File
@@ -18,6 +18,7 @@ from app.views.queue import router as queue_router
from app.views.search import router as search_router
from app.views.settings import router as settings_router
from app.views.status import router as status_router
from app.views.subscriptions import router as subscriptions_router
from app.views.wizard import router as wizard_router
# Create a main router that includes all the view routers
@@ -35,3 +36,4 @@ router.include_router(settings_router)
router.include_router(filemanager_router)
router.include_router(search_router)
router.include_router(queue_router)
router.include_router(subscriptions_router) # Pricing + subscription pages
+63 -11
View File
@@ -2,11 +2,12 @@
General routes for the application homepage and basic pages.
"""
from datetime import date
from datetime import date, datetime, timezone
from pathlib import Path
from fastapi import Depends, HTTPException, Request
from fastapi.responses import FileResponse, RedirectResponse
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.utils.config_validator import get_provider_status, validate_storage_configs
@@ -54,25 +55,76 @@ async def serve_index(request: Request, db: Session = Depends(get_db)):
and provider in ["dropbox", "nextcloud", "sftp", "s3", "ftp", "webdav", "google_drive", "onedrive"]
)
# Query the actual file count from the database
processed_files = 0
from app.models import FileRecord
today = datetime.now(timezone.utc).date()
# Global file counts (or per-user in multi-user mode)
from app.config import settings
user = request.session.get("user") or {}
is_admin = user.get("is_admin", False)
try:
# Import the model here to avoid circular imports
from app.models import FileRecord
total_files: int = db.query(func.count(FileRecord.id)).scalar() or 0
processed_files = db.query(FileRecord.id).count()
files_today: int = (
db.query(func.count(FileRecord.id)).filter(func.date(FileRecord.created_at) == today).scalar() or 0
)
files_month: int = (
db.query(func.count(FileRecord.id))
.filter(func.strftime("%Y-%m", FileRecord.created_at) == today.strftime("%Y-%m"))
.scalar()
or 0
)
files_with_ocr: int = db.query(func.count(FileRecord.id)).filter(FileRecord.ocr_text.isnot(None)).scalar() or 0
unique_users: int = (
db.query(func.count(func.distinct(FileRecord.owner_id))).filter(FileRecord.owner_id.isnot(None)).scalar()
or 0
)
except Exception as e:
# Log error but continue (don't break the page if DB query fails)
logger.error(f"Error counting files: {str(e)}")
logger.error(f"Error computing dashboard stats: {e}")
total_files = files_today = files_month = files_with_ocr = unique_users = 0
# Per-user usage for the subscription widget (multi-user only)
user_usage = None
user_tier = None
if settings.multi_user_enabled:
owner_id: str = user.get("username") or user.get("email") or user.get("sub") or ""
if owner_id:
try:
from app.utils.subscription import get_tier, get_user_tier_id, get_user_usage
tier_id = get_user_tier_id(db, owner_id)
user_tier = get_tier(tier_id)
user_usage = get_user_usage(db, owner_id)
except Exception as e:
logger.error(f"Error fetching subscription info: {e}")
# Create stats object to pass to the template
stats = {
"processed_files": processed_files,
"processed_files": total_files,
"files_today": files_today,
"files_month": files_month,
"files_with_ocr": files_with_ocr,
"unique_users": unique_users,
"active_integrations": configured_providers,
"storage_targets": configured_storage_targets,
}
return templates.TemplateResponse("index.html", {"request": request, "stats": stats})
return templates.TemplateResponse(
"index.html",
{
"request": request,
"stats": stats,
"user_usage": user_usage,
"user_tier": user_tier,
"multi_user_enabled": settings.multi_user_enabled,
"is_admin": is_admin,
},
)
@router.get("/about", include_in_schema=False)
+64
View File
@@ -0,0 +1,64 @@
"""View routes for subscription-related pages.
Routes:
GET /pricing — public marketing pricing page
GET /subscription — authenticated user's current plan & usage
"""
import logging
from fastapi import Depends, Request
from sqlalchemy.orm import Session
from app.utils.subscription import TIER_ORDER, get_all_tiers, get_tier, get_user_tier_id, get_user_usage
from app.views.base import APIRouter, get_db, require_login, templates
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/pricing", include_in_schema=False)
async def pricing_page(request: Request):
"""Public-facing pricing and plans page."""
tiers = get_all_tiers()
return templates.TemplateResponse(
"pricing.html",
{
"request": request,
"tiers": tiers,
"tier_order": TIER_ORDER,
},
)
@router.get("/subscription", include_in_schema=False)
@require_login
async def my_subscription_page(request: Request, db: Session = Depends(get_db)):
"""Authenticated user's subscription status and usage page."""
from app.config import settings
user = request.session.get("user") or {}
owner_id: str = user.get("username") or user.get("email") or user.get("sub") or ""
if settings.multi_user_enabled and owner_id:
tier_id = get_user_tier_id(db, owner_id)
usage = get_user_usage(db, owner_id)
else:
tier_id = "business"
usage = None
tier = get_tier(tier_id)
all_tiers = get_all_tiers()
return templates.TemplateResponse(
"subscription.html",
{
"request": request,
"tier": tier,
"tier_id": tier_id,
"usage": usage,
"all_tiers": all_tiers,
"multi_user_enabled": settings.multi_user_enabled,
"owner_id": owner_id,
},
)
+151
View File
@@ -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).
+13 -3
View File
@@ -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);
}
+50 -3
View File
@@ -68,6 +68,7 @@
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Display Name</th>
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Documents</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Last Upload</th>
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Plan</th>
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Upload Limit</th>
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
@@ -76,14 +77,14 @@
<tbody class="bg-white divide-y divide-gray-200">
<template x-if="loading">
<tr>
<td colspan="7" class="px-4 py-8 text-center text-gray-400">
<td colspan="8" class="px-4 py-8 text-center text-gray-400">
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i> Loading users…
</td>
</tr>
</template>
<template x-if="!loading && users.length === 0">
<tr>
<td colspan="7" class="px-4 py-8 text-center text-gray-400">
<td colspan="8" class="px-4 py-8 text-center text-gray-400">
<i class="fas fa-users-slash text-3xl block mb-2" aria-hidden="true"></i>
No users found.
<span x-show="search"> Try a different search term.</span>
@@ -117,6 +118,29 @@
<!-- Last upload -->
<td class="px-4 py-3 text-sm text-gray-500 whitespace-nowrap"
x-text="user.last_upload ? formatDate(user.last_upload) : '—'"></td>
<!-- Subscription plan -->
<td class="px-4 py-3 text-sm text-center">
<span
:class="{
'bg-gray-100 text-gray-600': !user.subscription_tier || user.subscription_tier === 'free',
'bg-blue-100 text-blue-700': user.subscription_tier === 'starter',
'bg-indigo-100 text-indigo-700': user.subscription_tier === 'professional',
'bg-purple-100 text-purple-700': user.subscription_tier === 'business',
}"
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold capitalize"
>
<i
:class="{
'fas fa-seedling': !user.subscription_tier || user.subscription_tier === 'free',
'fas fa-rocket': user.subscription_tier === 'starter',
'fas fa-star': user.subscription_tier === 'professional',
'fas fa-building': user.subscription_tier === 'business',
}"
aria-hidden="true"
></i>
<span x-text="user.subscription_tier || 'free'"></span>
</span>
</td>
<!-- Upload limit -->
<td class="px-4 py-3 text-sm text-center text-gray-700">
<span x-show="user.daily_upload_limit !== null && user.daily_upload_limit !== undefined"
@@ -300,6 +324,26 @@
</label>
</div>
<!-- Subscription tier -->
<div>
<label for="modal-tier" class="block text-sm font-medium text-gray-700 mb-1">
Subscription Plan
</label>
<select
id="modal-tier"
x-model="form.subscription_tier"
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"
>
<option value="free">Free — 25 lifetime files</option>
<option value="starter">Starter — $9/mo (10/day, 100/mo)</option>
<option value="professional">Professional — $29/mo (50/day, 500/mo)</option>
<option value="business">Business — $79/mo (unlimited)</option>
</select>
<p class="text-xs text-gray-400 mt-1">
Sets the quota limits for this user. Limits are enforced on upload.
</p>
</div>
</div>
<!-- Footer -->
@@ -394,6 +438,7 @@ function adminUsersApp() {
daily_upload_limit: null,
notes: '',
is_blocked: false,
subscription_tier: 'free',
},
// Delete modal
@@ -446,7 +491,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 };
this.form = { user_id: '', display_name: '', daily_upload_limit: null, notes: '', is_blocked: false, subscription_tier: 'free' };
this.modalOpen = true;
},
@@ -460,6 +505,7 @@ function adminUsersApp() {
? user.daily_upload_limit : null,
notes: user.notes || '',
is_blocked: !!user.is_blocked,
subscription_tier: user.subscription_tier || 'free',
};
this.modalOpen = true;
},
@@ -473,6 +519,7 @@ function adminUsersApp() {
? Number(this.form.daily_upload_limit) : null,
notes: this.form.notes || null,
is_blocked: !!this.form.is_blocked,
subscription_tier: this.form.subscription_tier || 'free',
};
const uid = encodeURIComponent(this.form.user_id);
const resp = await fetch(`/api/admin/users/${uid}`, {
+2
View File
@@ -63,6 +63,7 @@
<a href="/upload" class="text-gray-700 hover:text-gray-900" {% if request and request.url.path == '/upload' %}aria-current="page"{% endif %}>Upload</a>
<a href="/files" class="text-gray-700 hover:text-gray-900" {% if request and request.url.path == '/files' %}aria-current="page"{% endif %}>Files</a>
<a href="/search" class="text-gray-700 hover:text-gray-900" {% if request and request.url.path == '/search' %}aria-current="page"{% endif %}>Search</a>
<a href="/pricing" class="text-gray-700 hover:text-gray-900" {% if request and request.url.path == '/pricing' %}aria-current="page"{% endif %}>Pricing</a>
<!-- Admin dropdown (shown only for admin users via JS) -->
<div id="adminMenuContainer" class="relative hidden">
@@ -171,6 +172,7 @@
<a href="/upload" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">Upload</a>
<a href="/files" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">Files</a>
<a href="/search" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">Search</a>
<a href="/pricing" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">Pricing</a>
<!-- Admin section in mobile menu (shown only for admin users via JS) -->
<div id="mobileAdminSection" class="hidden">
+298
View File
@@ -4,6 +4,302 @@
{% block content %}
<div class="container mx-auto px-4 py-8">
{% if multi_user_enabled %}
{# ══════════════════════════════════════════════════════════════════════════ #}
{# MULTI-USER / SAAS DASHBOARD #}
{# ══════════════════════════════════════════════════════════════════════════ #}
<!-- Hero -->
<div class="bg-gradient-to-r from-blue-600 to-indigo-700 rounded-xl shadow-lg text-white p-8 mb-8">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 class="text-3xl font-bold mb-1">DocuElevate</h1>
<p class="text-blue-100 text-sm">Intelligent document processing &amp; management — built for teams</p>
</div>
<div class="flex flex-wrap gap-3">
<a href="/upload"
class="bg-white text-blue-700 hover:bg-blue-50 font-semibold py-2 px-5 rounded-lg transition duration-200 flex items-center shadow-sm">
<i class="fas fa-upload mr-2" aria-hidden="true"></i> Upload
</a>
<a href="/files"
class="bg-transparent border border-white text-white hover:bg-white hover:text-blue-700 font-semibold py-2 px-5 rounded-lg transition duration-200 flex items-center">
<i class="fas fa-list mr-2" aria-hidden="true"></i> Browse Files
</a>
</div>
</div>
</div>
<!-- Platform stats row (admin only) -->
{% if is_admin %}
<h2 class="text-sm font-semibold text-gray-400 uppercase tracking-widest mb-3">Platform overview</h2>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-8">
<div class="bg-white rounded-xl shadow p-5 flex items-center gap-3">
<div class="h-10 w-10 rounded-full bg-blue-100 flex items-center justify-center flex-shrink-0">
<i class="fas fa-file-alt text-blue-600" aria-hidden="true"></i>
</div>
<div>
<p class="text-2xl font-extrabold text-blue-600">{{ stats.processed_files | default(0) }}</p>
<p class="text-gray-500 text-xs">Total files</p>
</div>
</div>
<div class="bg-white rounded-xl shadow p-5 flex items-center gap-3">
<div class="h-10 w-10 rounded-full bg-green-100 flex items-center justify-center flex-shrink-0">
<i class="fas fa-calendar-day text-green-600" aria-hidden="true"></i>
</div>
<div>
<p class="text-2xl font-extrabold text-green-600">{{ stats.files_today | default(0) }}</p>
<p class="text-gray-500 text-xs">Files today</p>
</div>
</div>
<div class="bg-white rounded-xl shadow p-5 flex items-center gap-3">
<div class="h-10 w-10 rounded-full bg-indigo-100 flex items-center justify-center flex-shrink-0">
<i class="fas fa-calendar-alt text-indigo-600" aria-hidden="true"></i>
</div>
<div>
<p class="text-2xl font-extrabold text-indigo-600">{{ stats.files_month | default(0) }}</p>
<p class="text-gray-500 text-xs">Files this month</p>
</div>
</div>
<div class="bg-white rounded-xl shadow p-5 flex items-center gap-3">
<div class="h-10 w-10 rounded-full bg-purple-100 flex items-center justify-center flex-shrink-0">
<i class="fas fa-users text-purple-600" aria-hidden="true"></i>
</div>
<div>
<p class="text-2xl font-extrabold text-purple-600">{{ stats.unique_users | default(0) }}</p>
<p class="text-gray-500 text-xs">Active users</p>
</div>
</div>
</div>
{% endif %}
<!-- My usage / subscription widget -->
{% if user_tier and user_usage %}
<h2 class="text-sm font-semibold text-gray-400 uppercase tracking-widest mb-3">My usage</h2>
<div class="bg-white rounded-xl shadow p-6 mb-8">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
<div class="flex items-center gap-3">
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-bold
{% if user_tier.id == 'free' %}bg-gray-100 text-gray-600
{% elif user_tier.id == 'starter' %}bg-blue-100 text-blue-700
{% elif user_tier.id == 'professional' %}bg-indigo-100 text-indigo-700
{% else %}bg-purple-100 text-purple-700{% endif %}">
<i class="fas
{% if user_tier.id == 'free' %}fa-seedling
{% elif user_tier.id == 'starter' %}fa-rocket
{% elif user_tier.id == 'professional' %}fa-star
{% else %}fa-building{% endif %}
mr-1" aria-hidden="true"></i>
{{ user_tier.name }} Plan
</span>
{% if user_tier.id != 'business' %}
<a href="/pricing" class="text-xs text-indigo-600 hover:text-indigo-800 font-medium">
Upgrade <i class="fas fa-arrow-up-right-from-square ml-0.5 text-xs" aria-hidden="true"></i>
</a>
{% endif %}
</div>
<a href="/subscription" class="text-xs text-gray-500 hover:text-gray-700">
View full details →
</a>
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<!-- Lifetime -->
<div>
<div class="flex justify-between text-xs mb-1">
<span class="text-gray-500">Lifetime files</span>
<span class="font-semibold text-gray-700">
{{ user_usage.lifetime }}{% if user_tier.lifetime_file_limit > 0 %} / {{ user_tier.lifetime_file_limit }}{% endif %}
</span>
</div>
{% if user_tier.lifetime_file_limit > 0 %}
{% set pct = ([((user_usage.lifetime / user_tier.lifetime_file_limit) * 100) | int, 100] | min) %}
<div class="w-full bg-gray-100 rounded-full h-2" role="progressbar"
aria-valuenow="{{ user_usage.lifetime }}" aria-valuemax="{{ user_tier.lifetime_file_limit }}">
<div class="h-2 rounded-full {% if pct >= 90 %}bg-red-500{% elif pct >= 70 %}bg-yellow-500{% else %}bg-green-500{% endif %}"
style="width: {{ pct }}%"></div>
</div>
{% else %}
<div class="text-xs text-green-600">Unlimited</div>
{% endif %}
</div>
<!-- Today -->
<div>
<div class="flex justify-between text-xs mb-1">
<span class="text-gray-500">Files today</span>
<span class="font-semibold text-gray-700">
{{ user_usage.today }}{% if user_tier.daily_upload_limit > 0 %} / {{ user_tier.daily_upload_limit }}{% endif %}
</span>
</div>
{% if user_tier.daily_upload_limit > 0 %}
{% set pct = ([((user_usage.today / user_tier.daily_upload_limit) * 100) | int, 100] | min) %}
<div class="w-full bg-gray-100 rounded-full h-2" role="progressbar"
aria-valuenow="{{ user_usage.today }}" aria-valuemax="{{ user_tier.daily_upload_limit }}">
<div class="h-2 rounded-full {% if pct >= 90 %}bg-red-500{% elif pct >= 70 %}bg-yellow-500{% else %}bg-blue-500{% endif %}"
style="width: {{ pct }}%"></div>
</div>
{% else %}
<div class="text-xs text-green-600">Unlimited</div>
{% endif %}
</div>
<!-- This month -->
<div>
<div class="flex justify-between text-xs mb-1">
<span class="text-gray-500">Files this month</span>
<span class="font-semibold text-gray-700">
{{ user_usage.month }}{% if user_tier.monthly_upload_limit > 0 %} / {{ user_tier.monthly_upload_limit }}{% endif %}
</span>
</div>
{% if user_tier.monthly_upload_limit > 0 %}
{% set pct = ([((user_usage.month / user_tier.monthly_upload_limit) * 100) | int, 100] | min) %}
<div class="w-full bg-gray-100 rounded-full h-2" role="progressbar"
aria-valuenow="{{ user_usage.month }}" aria-valuemax="{{ user_tier.monthly_upload_limit }}">
<div class="h-2 rounded-full {% if pct >= 90 %}bg-red-500{% elif pct >= 70 %}bg-yellow-500{% else %}bg-indigo-500{% endif %}"
style="width: {{ pct }}%"></div>
</div>
{% else %}
<div class="text-xs text-green-600">Unlimited</div>
{% endif %}
</div>
</div>
</div>
{% endif %}
<!-- Widget grid (multi-user) -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
<!-- Quick Actions -->
<div class="bg-white rounded-xl shadow p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
<i class="fas fa-bolt text-yellow-500" aria-hidden="true"></i> Quick Actions
</h2>
<ul class="space-y-2">
<li>
<a href="/upload" class="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 transition group">
<span class="h-8 w-8 rounded-full bg-blue-100 flex items-center justify-center flex-shrink-0 group-hover:bg-blue-200">
<i class="fas fa-upload text-blue-600 text-sm" aria-hidden="true"></i>
</span>
<div>
<p class="text-sm font-medium text-gray-800">Upload Document</p>
<p class="text-xs text-gray-400">Process a new file</p>
</div>
</a>
</li>
<li>
<a href="/files" class="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 transition group">
<span class="h-8 w-8 rounded-full bg-indigo-100 flex items-center justify-center flex-shrink-0 group-hover:bg-indigo-200">
<i class="fas fa-list text-indigo-600 text-sm" aria-hidden="true"></i>
</span>
<div>
<p class="text-sm font-medium text-gray-800">My Documents</p>
<p class="text-xs text-gray-400">Browse your processed files</p>
</div>
</a>
</li>
<li>
<a href="/subscription" class="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 transition group">
<span class="h-8 w-8 rounded-full bg-purple-100 flex items-center justify-center flex-shrink-0 group-hover:bg-purple-200">
<i class="fas fa-layer-group text-purple-600 text-sm" aria-hidden="true"></i>
</span>
<div>
<p class="text-sm font-medium text-gray-800">My Subscription</p>
<p class="text-xs text-gray-400">View plan &amp; usage details</p>
</div>
</a>
</li>
<li>
<a href="/search" class="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 transition group">
<span class="h-8 w-8 rounded-full bg-green-100 flex items-center justify-center flex-shrink-0 group-hover:bg-green-200">
<i class="fas fa-search text-green-600 text-sm" aria-hidden="true"></i>
</span>
<div>
<p class="text-sm font-medium text-gray-800">Search</p>
<p class="text-xs text-gray-400">Full-text search across documents</p>
</div>
</a>
</li>
</ul>
</div>
<!-- Processing stats -->
<div class="bg-white rounded-xl shadow p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
<i class="fas fa-chart-bar text-blue-500" aria-hidden="true"></i> Processing Stats
</h2>
<ul class="space-y-3 text-sm">
<li class="flex items-center justify-between py-2 border-b border-gray-100">
<span class="text-gray-600 flex items-center gap-2">
<i class="fas fa-file-alt text-blue-400 w-4" aria-hidden="true"></i> Total processed
</span>
<span class="font-bold text-gray-900">{{ stats.processed_files | default(0) }}</span>
</li>
<li class="flex items-center justify-between py-2 border-b border-gray-100">
<span class="text-gray-600 flex items-center gap-2">
<i class="fas fa-eye text-indigo-400 w-4" aria-hidden="true"></i> Files with OCR
</span>
<span class="font-bold text-gray-900">{{ stats.files_with_ocr | default(0) }}</span>
</li>
<li class="flex items-center justify-between py-2 border-b border-gray-100">
<span class="text-gray-600 flex items-center gap-2">
<i class="fas fa-calendar-day text-green-400 w-4" aria-hidden="true"></i> Today
</span>
<span class="font-bold text-gray-900">{{ stats.files_today | default(0) }}</span>
</li>
<li class="flex items-center justify-between py-2">
<span class="text-gray-600 flex items-center gap-2">
<i class="fas fa-calendar-alt text-orange-400 w-4" aria-hidden="true"></i> This month
</span>
<span class="font-bold text-gray-900">{{ stats.files_month | default(0) }}</span>
</li>
</ul>
</div>
<!-- Upgrade CTA or Integrations -->
{% if user_tier and user_tier.id != 'business' %}
<div class="bg-gradient-to-br from-indigo-600 to-purple-700 rounded-xl shadow p-6 text-white flex flex-col justify-between">
<div>
<h2 class="text-lg font-semibold mb-2 flex items-center gap-2">
<i class="fas fa-arrow-up-right-dots" aria-hidden="true"></i> Upgrade your plan
</h2>
<p class="text-indigo-100 text-sm mb-4">
Unlock more documents, more destinations and priority support.
</p>
<ul class="space-y-1.5 text-sm text-indigo-100">
<li class="flex items-center gap-2"><i class="fas fa-check-circle text-indigo-300" aria-hidden="true"></i> Higher daily &amp; monthly limits</li>
<li class="flex items-center gap-2"><i class="fas fa-check-circle text-indigo-300" aria-hidden="true"></i> More storage destinations</li>
<li class="flex items-center gap-2"><i class="fas fa-check-circle text-indigo-300" aria-hidden="true"></i> More OCR pages</li>
</ul>
</div>
<a href="/pricing"
class="mt-6 inline-flex items-center justify-center bg-white text-indigo-700 font-semibold px-5 py-2.5 rounded-lg hover:bg-indigo-50 transition shadow">
View plans &amp; pricing <i class="fas fa-arrow-right ml-2 text-xs" aria-hidden="true"></i>
</a>
</div>
{% else %}
<div class="bg-white rounded-xl shadow p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
<i class="fas fa-plug text-green-500" aria-hidden="true"></i> Integrations
</h2>
<div class="space-y-3 text-sm text-gray-600">
<div class="flex items-center justify-between">
<span>Active integrations</span>
<span class="font-bold text-green-600">{{ stats.active_integrations | default(0) }}</span>
</div>
<div class="flex items-center justify-between">
<span>Storage targets</span>
<span class="font-bold text-indigo-600">{{ stats.storage_targets | default(0) }}</span>
</div>
</div>
<a href="/status" class="mt-4 inline-flex items-center text-xs text-indigo-600 hover:text-indigo-800">
View system status <i class="fas fa-arrow-right ml-1" aria-hidden="true"></i>
</a>
</div>
{% endif %}
</div>
{% else %}
{# ══════════════════════════════════════════════════════════════════════════ #}
{# SINGLE-USER DASHBOARD (original layout, preserved as-is) #}
{# ══════════════════════════════════════════════════════════════════════════ #}
<!-- Hero / Quick Actions -->
<div class="bg-gradient-to-r from-blue-600 to-indigo-700 rounded-xl shadow-lg text-white p-8 mb-8">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
@@ -154,6 +450,8 @@
</div>
</div>
{% endif %}{# end multi_user_enabled #}
</div>
{% endblock %}
+373
View File
@@ -0,0 +1,373 @@
{% extends "base.html" %}
{% block title %}Pricing & Plans DocuElevate{% endblock %}
{% block content %}
<div class="bg-gray-50 min-h-screen">
<!-- ── Hero ──────────────────────────────────────────────────────────────── -->
<div class="bg-gradient-to-br from-blue-700 via-indigo-700 to-purple-700 text-white py-20 px-4">
<div class="max-w-4xl mx-auto text-center">
<span class="inline-block bg-white/20 text-white text-xs font-semibold uppercase tracking-widest px-3 py-1 rounded-full mb-4">
Simple, transparent pricing
</span>
<h1 class="text-4xl sm:text-5xl font-extrabold mb-4 leading-tight">
Choose the plan that's right for you
</h1>
<p class="text-indigo-100 text-lg max-w-2xl mx-auto">
From free exploration to unlimited enterprise processing — scale as your document workflows grow.
</p>
<!-- Annual / Monthly toggle (cosmetic — actual billing handled separately) -->
<div class="mt-8 inline-flex items-center bg-white/10 rounded-full p-1 gap-1" x-data="{ annual: false }">
<button
@click="annual = false"
:class="!annual ? 'bg-white text-indigo-700 shadow' : 'text-white'"
class="px-5 py-2 rounded-full text-sm font-semibold transition-all duration-200"
>Monthly</button>
<button
@click="annual = true"
:class="annual ? 'bg-white text-indigo-700 shadow' : 'text-white'"
class="px-5 py-2 rounded-full text-sm font-semibold transition-all duration-200"
>
Annual <span class="ml-1 text-xs bg-green-400 text-green-900 rounded-full px-2 py-0.5 font-bold">Save ~17%</span>
</button>
<!-- Tier cards — responsive 4-column grid -->
<div class="hidden" x-effect="$el.classList.remove('hidden')"></div>
</div>
</div>
</div>
<!-- ── Tier cards ────────────────────────────────────────────────────────── -->
<div class="max-w-7xl mx-auto px-4 -mt-10 pb-20" x-data="{ annual: false }">
<!-- Recreate the toggle state here so cards react to the hero toggle too -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 items-stretch">
{% for tier in tiers %}
<div class="relative flex flex-col rounded-2xl shadow-lg overflow-hidden
{% if tier.highlight %}ring-4 ring-indigo-500 scale-105 z-10 bg-white{% else %}bg-white{% endif %}
transition-transform duration-200 hover:shadow-xl"
>
<!-- Popular badge -->
{% if tier.badge %}
<div class="absolute top-0 right-0 mt-4 mr-4">
<span class="
{% if tier.badge == 'Most Popular' %}bg-indigo-600 text-white
{% elif tier.badge == 'Best Value' %}bg-green-600 text-white
{% else %}bg-gray-600 text-white{% endif %}
text-xs font-bold uppercase tracking-wide px-3 py-1 rounded-full shadow"
>
{{ tier.badge }}
</span>
</div>
{% endif %}
<!-- Card header -->
<div class="p-6 pb-4 border-b border-gray-100">
<h2 class="text-xl font-bold text-gray-900">{{ tier.name }}</h2>
<p class="text-gray-500 text-sm mt-1">{{ tier.tagline }}</p>
<!-- Price -->
<div class="mt-4">
<template x-if="!annual">
<div>
{% if tier.price_monthly == 0 %}
<span class="text-4xl font-extrabold text-gray-900">Free</span>
{% else %}
<span class="text-4xl font-extrabold text-gray-900">${{ tier.price_monthly }}</span>
<span class="text-gray-500 text-sm">/month</span>
{% endif %}
</div>
</template>
<template x-if="annual">
<div>
{% if tier.price_yearly == 0 %}
<span class="text-4xl font-extrabold text-gray-900">Free</span>
{% else %}
<span class="text-4xl font-extrabold text-gray-900">${{ tier.price_yearly }}</span>
<span class="text-gray-500 text-sm">/year</span>
<div class="text-xs text-green-600 font-semibold mt-0.5">
~${{ (tier.price_yearly / 12) | round(0) | int }}/month billed annually
</div>
{% endif %}
</div>
</template>
</div>
</div>
<!-- Features list -->
<div class="p-6 flex-1">
<ul class="space-y-3 text-sm text-gray-600">
{% for feature in tier.features %}
<li class="flex items-start gap-2">
<i class="fas fa-check-circle text-green-500 mt-0.5 flex-shrink-0" aria-hidden="true"></i>
<span>{{ feature }}</span>
</li>
{% endfor %}
</ul>
</div>
<!-- CTA button -->
<div class="p-6 pt-0">
{% if tier.id == 'free' %}
<a href="/login"
class="block w-full text-center py-3 px-4 rounded-lg border-2 border-blue-600 text-blue-600 font-semibold hover:bg-blue-50 transition"
>{{ tier.cta }}</a>
{% elif tier.id == 'business' %}
<a href="mailto:sales@docuelevate.io?subject=Business+Plan+Enquiry"
class="block w-full text-center py-3 px-4 rounded-lg bg-gray-800 text-white font-semibold hover:bg-gray-700 transition"
>{{ tier.cta }}</a>
{% elif tier.highlight %}
<a href="/login"
class="block w-full text-center py-3 px-4 rounded-lg bg-indigo-600 text-white font-semibold hover:bg-indigo-700 transition shadow-md"
>{{ tier.cta }}</a>
{% else %}
<a href="/login"
class="block w-full text-center py-3 px-4 rounded-lg bg-blue-600 text-white font-semibold hover:bg-blue-700 transition"
>{{ tier.cta }}</a>
{% endif %}
</div>
</div>
{% endfor %}
</div>
<!-- ── Comparison table ───────────────────────────────────────────────── -->
<div class="mt-20">
<h2 class="text-2xl font-bold text-gray-900 text-center mb-8">Full feature comparison</h2>
<div class="overflow-x-auto rounded-2xl shadow">
<table class="min-w-full bg-white divide-y divide-gray-200" aria-label="Plan comparison">
<thead>
<tr class="bg-gray-50">
<th scope="col" class="px-6 py-4 text-left text-sm font-semibold text-gray-700 w-1/3">Feature</th>
{% for tier in tiers %}
<th scope="col" class="px-4 py-4 text-center text-sm font-semibold
{% if tier.highlight %}text-indigo-700{% else %}text-gray-700{% endif %}">
{{ tier.name }}
</th>
{% endfor %}
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<tr class="bg-gray-50/50">
<th scope="row" colspan="5" class="px-6 py-2 text-xs font-bold text-gray-400 uppercase tracking-wider text-left">
File Processing
</th>
</tr>
<tr class="hover:bg-indigo-50/30 transition">
<td class="px-6 py-3 text-sm text-gray-700">Lifetime file limit</td>
{% for tier in tiers %}
<td class="px-4 py-3 text-center text-sm">
{% if tier.lifetime_file_limit == 0 %}
<span class="font-semibold text-green-600">Unlimited</span>
{% else %}
<span class="font-medium text-gray-800">{{ tier.lifetime_file_limit }}</span>
{% endif %}
</td>
{% endfor %}
</tr>
<tr class="hover:bg-indigo-50/30 transition">
<td class="px-6 py-3 text-sm text-gray-700">Files per day</td>
{% for tier in tiers %}
<td class="px-4 py-3 text-center text-sm">
{% if tier.daily_upload_limit == 0 %}
<span class="font-semibold text-green-600">Unlimited</span>
{% else %}
<span class="font-medium text-gray-800">{{ tier.daily_upload_limit }}</span>
{% endif %}
</td>
{% endfor %}
</tr>
<tr class="hover:bg-indigo-50/30 transition">
<td class="px-6 py-3 text-sm text-gray-700">Files per month</td>
{% for tier in tiers %}
<td class="px-4 py-3 text-center text-sm">
{% if tier.monthly_upload_limit == 0 %}
<span class="font-semibold text-green-600">Unlimited</span>
{% else %}
<span class="font-medium text-gray-800">{{ tier.monthly_upload_limit }}</span>
{% endif %}
</td>
{% endfor %}
</tr>
<tr class="hover:bg-indigo-50/30 transition">
<td class="px-6 py-3 text-sm text-gray-700">Max file size</td>
{% for tier in tiers %}
<td class="px-4 py-3 text-center text-sm">
{% if tier.max_file_size_mb == 0 %}
<span class="font-semibold text-green-600">Unlimited</span>
{% else %}
<span class="font-medium text-gray-800">{{ tier.max_file_size_mb }} MB</span>
{% endif %}
</td>
{% endfor %}
</tr>
<tr class="bg-gray-50/50">
<th scope="row" colspan="5" class="px-6 py-2 text-xs font-bold text-gray-400 uppercase tracking-wider text-left">
OCR & AI
</th>
</tr>
<tr class="hover:bg-indigo-50/30 transition">
<td class="px-6 py-3 text-sm text-gray-700">OCR pages / month</td>
{% for tier in tiers %}
<td class="px-4 py-3 text-center text-sm">
{% if tier.max_ocr_pages_monthly == 0 %}
<span class="font-semibold text-green-600">Unlimited</span>
{% else %}
<span class="font-medium text-gray-800">{{ tier.max_ocr_pages_monthly | int | string | replace("2500", "2 500") }}</span>
{% endif %}
</td>
{% endfor %}
</tr>
<tr class="hover:bg-indigo-50/30 transition">
<td class="px-6 py-3 text-sm text-gray-700">AI metadata extraction</td>
<td class="px-4 py-3 text-center">
<span class="text-xs text-gray-500">Basic</span>
</td>
{% for tier in tiers[1:] %}
<td class="px-4 py-3 text-center">
<i class="fas fa-check text-green-500" aria-label="Included"></i>
</td>
{% endfor %}
</tr>
<tr class="bg-gray-50/50">
<th scope="row" colspan="5" class="px-6 py-2 text-xs font-bold text-gray-400 uppercase tracking-wider text-left">
Integrations
</th>
</tr>
<tr class="hover:bg-indigo-50/30 transition">
<td class="px-6 py-3 text-sm text-gray-700">Storage destinations</td>
{% for tier in tiers %}
<td class="px-4 py-3 text-center text-sm">
{% if tier.max_storage_destinations == 0 %}
<span class="font-semibold text-green-600">Unlimited</span>
{% else %}
<span class="font-medium text-gray-800">{{ tier.max_storage_destinations }}</span>
{% endif %}
</td>
{% endfor %}
</tr>
<tr class="hover:bg-indigo-50/30 transition">
<td class="px-6 py-3 text-sm text-gray-700">Email ingestion</td>
<td class="px-4 py-3 text-center">
<i class="fas fa-times text-gray-300" aria-label="Not included"></i>
</td>
{% for tier in tiers[1:] %}
<td class="px-4 py-3 text-center">
<i class="fas fa-check text-green-500" aria-label="Included"></i>
</td>
{% endfor %}
</tr>
<tr class="hover:bg-indigo-50/30 transition">
<td class="px-6 py-3 text-sm text-gray-700">Webhooks</td>
<td class="px-4 py-3 text-center">
<i class="fas fa-times text-gray-300" aria-label="Not included"></i>
</td>
<td class="px-4 py-3 text-center">
<i class="fas fa-times text-gray-300" aria-label="Not included"></i>
</td>
<td class="px-4 py-3 text-center">
<i class="fas fa-check text-green-500" aria-label="Included"></i>
</td>
<td class="px-4 py-3 text-center">
<i class="fas fa-check text-green-500" aria-label="Included"></i>
</td>
</tr>
<tr class="hover:bg-indigo-50/30 transition">
<td class="px-6 py-3 text-sm text-gray-700">API access</td>
<td class="px-4 py-3 text-center">
<i class="fas fa-times text-gray-300" aria-label="Not included"></i>
</td>
{% for tier in tiers[1:] %}
<td class="px-4 py-3 text-center">
<i class="fas fa-check text-green-500" aria-label="Included"></i>
</td>
{% endfor %}
</tr>
<tr class="bg-gray-50/50">
<th scope="row" colspan="5" class="px-6 py-2 text-xs font-bold text-gray-400 uppercase tracking-wider text-left">
Support
</th>
</tr>
<tr class="hover:bg-indigo-50/30 transition">
<td class="px-6 py-3 text-sm text-gray-700">Support level</td>
<td class="px-4 py-3 text-center text-xs text-gray-500">Community</td>
<td class="px-4 py-3 text-center text-xs text-gray-700">Email</td>
<td class="px-4 py-3 text-center text-xs text-gray-700 font-medium">Priority email</td>
<td class="px-4 py-3 text-center text-xs text-indigo-700 font-bold">Dedicated</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- ── FAQ ───────────────────────────────────────────────────────────── -->
<div class="mt-20 max-w-3xl mx-auto">
<h2 class="text-2xl font-bold text-gray-900 text-center mb-8">Frequently asked questions</h2>
<div class="space-y-4" x-data="{ open: null }">
{% set faqs = [
("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."),
("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."),
("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."),
] %}
{% for q, a in faqs %}
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<button
@click="open = (open === {{ loop.index0 }}) ? null : {{ loop.index0 }}"
class="w-full flex justify-between items-center px-6 py-4 text-left text-sm font-semibold text-gray-800 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500"
:aria-expanded="open === {{ loop.index0 }}"
>
<span>{{ q }}</span>
<i class="fas text-indigo-400 ml-4 flex-shrink-0 transition-transform duration-200"
:class="open === {{ loop.index0 }} ? 'fa-chevron-up' : 'fa-chevron-down'"
aria-hidden="true"></i>
</button>
<div x-show="open === {{ loop.index0 }}" x-transition class="px-6 pb-4 text-sm text-gray-600">
{{ a }}
</div>
</div>
{% endfor %}
</div>
</div>
<!-- ── CTA strip ─────────────────────────────────────────────────────── -->
<div class="mt-20 rounded-2xl bg-gradient-to-r from-blue-600 to-indigo-700 text-white p-10 text-center shadow-xl">
<h2 class="text-2xl font-bold mb-2">Ready to get started?</h2>
<p class="text-indigo-100 mb-6">Start with the free tier — no credit card required.</p>
<a href="/login"
class="inline-block bg-white text-indigo-700 font-bold px-8 py-3 rounded-lg hover:bg-indigo-50 transition shadow">
Create your free account
</a>
</div>
</div>
</div>
{% endblock %}
+218
View File
@@ -0,0 +1,218 @@
{% extends "base.html" %}
{% block title %}My Subscription DocuElevate{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8 max-w-4xl">
<!-- Header -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 flex items-center gap-3">
<i class="fas fa-layer-group text-indigo-500" aria-hidden="true"></i>
My Subscription
</h1>
<p class="text-gray-500 text-sm mt-1">Your current plan, usage and available upgrades.</p>
</div>
{% if not multi_user_enabled %}
<!-- Single-user notice -->
<div class="bg-blue-50 border border-blue-200 rounded-xl p-6 flex items-start gap-4">
<i class="fas fa-info-circle text-blue-500 text-2xl flex-shrink-0 mt-0.5" aria-hidden="true"></i>
<div>
<p class="font-semibold text-blue-800">Multi-user mode is not enabled.</p>
<p class="text-blue-700 text-sm mt-1">
Subscription tiers apply when multi-user mode is active. Your instance currently runs in
single-user mode with no processing limits.
An admin can enable multi-user mode via <a href="/settings" class="underline hover:text-blue-900">Settings</a>.
</p>
</div>
</div>
{% else %}
<!-- Current plan card -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<!-- Plan badge -->
<div class="bg-white rounded-xl shadow p-6 flex flex-col items-center text-center
{% if tier_id == 'professional' %}ring-2 ring-indigo-500{% endif %}">
<div class="h-14 w-14 rounded-full
{% if tier_id == 'free' %}bg-gray-100{% elif tier_id == 'starter' %}bg-blue-100{% elif tier_id == 'professional' %}bg-indigo-100{% else %}bg-purple-100{% endif %}
flex items-center justify-center mb-3">
<i class="fas
{% if tier_id == 'free' %}fa-seedling text-gray-500
{% elif tier_id == 'starter' %}fa-rocket text-blue-600
{% elif tier_id == 'professional' %}fa-star text-indigo-600
{% else %}fa-building text-purple-600{% endif %}
text-2xl" aria-hidden="true"></i>
</div>
<p class="text-xs font-semibold text-gray-400 uppercase tracking-widest">Current Plan</p>
<p class="text-2xl font-extrabold text-gray-900 mt-1">{{ tier.name }}</p>
<p class="text-gray-500 text-sm mt-1">{{ tier.tagline }}</p>
{% if tier.price_monthly > 0 %}
<p class="mt-3 text-lg font-bold text-indigo-600">${{ tier.price_monthly }}<span class="text-sm font-normal text-gray-400">/month</span></p>
{% else %}
<p class="mt-3 text-lg font-bold text-gray-500">Free</p>
{% endif %}
</div>
<!-- Usage this period -->
{% if usage %}
<div class="md:col-span-2 bg-white rounded-xl shadow p-6">
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-widest mb-4">Usage</h2>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<!-- Lifetime -->
<div class="text-center">
<p class="text-3xl font-extrabold text-gray-900">{{ usage.lifetime }}</p>
<p class="text-xs text-gray-500 mt-1">Total files (lifetime)</p>
{% if tier.lifetime_file_limit > 0 %}
<div class="mt-2">
{% set lifetime_pct = ((usage.lifetime / tier.lifetime_file_limit) * 100) | int %}
<div class="w-full bg-gray-200 rounded-full h-1.5" role="progressbar"
aria-valuenow="{{ usage.lifetime }}" aria-valuemax="{{ tier.lifetime_file_limit }}">
<div class="h-1.5 rounded-full
{% if lifetime_pct >= 90 %}bg-red-500{% elif lifetime_pct >= 70 %}bg-yellow-500{% else %}bg-green-500{% endif %}"
style="width: {{ [lifetime_pct, 100] | min }}%"></div>
</div>
<p class="text-xs text-gray-400 mt-1">{{ usage.lifetime }} / {{ tier.lifetime_file_limit }}</p>
</div>
{% else %}
<p class="text-xs text-green-600 mt-1">Unlimited</p>
{% endif %}
</div>
<!-- Today -->
<div class="text-center">
<p class="text-3xl font-extrabold text-gray-900">{{ usage.today }}</p>
<p class="text-xs text-gray-500 mt-1">Files today</p>
{% if tier.daily_upload_limit > 0 %}
<div class="mt-2">
{% set today_pct = ((usage.today / tier.daily_upload_limit) * 100) | int %}
<div class="w-full bg-gray-200 rounded-full h-1.5" role="progressbar"
aria-valuenow="{{ usage.today }}" aria-valuemax="{{ tier.daily_upload_limit }}">
<div class="h-1.5 rounded-full
{% if today_pct >= 90 %}bg-red-500{% elif today_pct >= 70 %}bg-yellow-500{% else %}bg-blue-500{% endif %}"
style="width: {{ [today_pct, 100] | min }}%"></div>
</div>
<p class="text-xs text-gray-400 mt-1">{{ usage.today }} / {{ tier.daily_upload_limit }} today</p>
</div>
{% else %}
<p class="text-xs text-green-600 mt-1">Unlimited</p>
{% endif %}
</div>
<!-- This month -->
<div class="text-center">
<p class="text-3xl font-extrabold text-gray-900">{{ usage.month }}</p>
<p class="text-xs text-gray-500 mt-1">Files this month</p>
{% if tier.monthly_upload_limit > 0 %}
<div class="mt-2">
{% set month_pct = ((usage.month / tier.monthly_upload_limit) * 100) | int %}
<div class="w-full bg-gray-200 rounded-full h-1.5" role="progressbar"
aria-valuenow="{{ usage.month }}" aria-valuemax="{{ tier.monthly_upload_limit }}">
<div class="h-1.5 rounded-full
{% if month_pct >= 90 %}bg-red-500{% elif month_pct >= 70 %}bg-yellow-500{% else %}bg-indigo-500{% endif %}"
style="width: {{ [month_pct, 100] | min }}%"></div>
</div>
<p class="text-xs text-gray-400 mt-1">{{ usage.month }} / {{ tier.monthly_upload_limit }} this month</p>
</div>
{% else %}
<p class="text-xs text-green-600 mt-1">Unlimited</p>
{% endif %}
</div>
</div>
</div>
{% endif %}
</div>
<!-- Plan features included -->
<div class="bg-white rounded-xl shadow p-6 mb-8">
<h2 class="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
<i class="fas fa-list-check text-green-500" aria-hidden="true"></i>
What's included in your plan
</h2>
<ul class="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm text-gray-600">
{% for feature in tier.features %}
<li class="flex items-start gap-2">
<i class="fas fa-check-circle text-green-500 mt-0.5 flex-shrink-0" aria-hidden="true"></i>
<span>{{ feature }}</span>
</li>
{% endfor %}
</ul>
</div>
<!-- Upgrade options (only show tiers above current) -->
{% set tier_order = ['free', 'starter', 'professional', 'business'] %}
{% set current_index = tier_order.index(tier_id) %}
{% set upgrade_tiers = all_tiers | selectattr('id', 'ne', tier_id) | list %}
{% if tier_id != 'business' %}
<div class="mb-8">
<h2 class="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
<i class="fas fa-arrow-up-right-dots text-indigo-500" aria-hidden="true"></i>
Upgrade your plan
</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{% for t in all_tiers %}
{% if tier_order.index(t.id) > current_index %}
<div class="bg-white rounded-xl border-2
{% if t.highlight %}border-indigo-500{% else %}border-gray-200{% endif %}
p-5 flex flex-col justify-between hover:shadow-md transition">
<div>
<div class="flex items-center justify-between mb-2">
<span class="font-bold text-gray-900">{{ t.name }}</span>
{% if t.badge %}
<span class="text-xs bg-indigo-100 text-indigo-700 font-semibold rounded-full px-2 py-0.5">{{ t.badge }}</span>
{% endif %}
</div>
<p class="text-gray-500 text-xs mb-3">{{ t.tagline }}</p>
{% if t.price_monthly > 0 %}
<p class="text-xl font-extrabold text-gray-900">${{ t.price_monthly }}<span class="text-sm font-normal text-gray-400">/mo</span></p>
{% endif %}
<ul class="mt-3 space-y-1.5 text-xs text-gray-600">
{% for feature in t.features[:4] %}
<li class="flex items-start gap-1.5">
<i class="fas fa-check-circle text-green-500 mt-0.5 flex-shrink-0" aria-hidden="true"></i>
{{ feature }}
</li>
{% endfor %}
{% if t.features | length > 4 %}
<li class="text-indigo-500 font-medium">+ {{ (t.features | length) - 4 }} more features</li>
{% endif %}
</ul>
</div>
<div class="mt-4">
{% if t.id == 'business' %}
<a href="mailto:sales@docuelevate.io?subject=Business+Plan+Enquiry"
class="block text-center py-2 px-4 rounded-lg bg-gray-800 text-white text-sm font-semibold hover:bg-gray-700 transition">
Contact Sales
</a>
{% else %}
<a href="/pricing"
class="block text-center py-2 px-4 rounded-lg
{% if t.highlight %}bg-indigo-600 hover:bg-indigo-700{% else %}bg-blue-600 hover:bg-blue-700{% endif %}
text-white text-sm font-semibold transition">
Upgrade to {{ t.name }}
</a>
{% endif %}
</div>
</div>
{% endif %}
{% endfor %}
</div>
</div>
{% endif %}
{% endif %}
<!-- Back link -->
<div class="mt-8">
<a href="/" class="text-sm text-indigo-600 hover:text-indigo-800 font-medium flex items-center gap-1">
<i class="fas fa-arrow-left text-xs" aria-hidden="true"></i> Back to Dashboard
</a>
</div>
</div>
{% endblock %}
@@ -0,0 +1,30 @@
"""Add subscription_tier column to user_profiles
Revision ID: 014_add_subscription_tiers
Revises: 013_add_user_profiles
Create Date: 2026-03-06
"""
from typing import Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "014_add_subscription_tiers"
down_revision: Union[str, None] = "013_add_user_profiles"
depends_on: Union[str, None] = None
def upgrade() -> None:
"""Add subscription_tier column to user_profiles."""
op.add_column(
"user_profiles",
sa.Column("subscription_tier", sa.String(50), nullable=True, server_default="free"),
)
def downgrade() -> None:
"""Remove subscription_tier column from user_profiles."""
op.drop_column("user_profiles", "subscription_tier")
+253
View File
@@ -0,0 +1,253 @@
"""Unit tests for the subscription tier utility module."""
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from unittest.mock import MagicMock, patch
from datetime import datetime, timezone, date
from app.utils.subscription import (
TIERS,
TIER_ORDER,
DEFAULT_TIER,
get_tier,
get_all_tiers,
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
# ---------------------------------------------------------------------------
@pytest.mark.unit
def test_all_tiers_present():
"""All four tier IDs must exist."""
for tid in ["free", "starter", "professional", "business"]:
assert tid in TIERS, f"Missing tier: {tid}"
@pytest.mark.unit
def test_tier_order_is_complete():
"""TIER_ORDER must contain exactly the four expected tiers."""
assert set(TIER_ORDER) == set(TIERS.keys())
assert len(TIER_ORDER) == 4
@pytest.mark.unit
def test_default_tier_is_free():
assert DEFAULT_TIER == "free"
@pytest.mark.unit
def test_get_tier_returns_correct_dict():
t = get_tier("starter")
assert t["id"] == "starter"
assert t["price_monthly"] == 9
@pytest.mark.unit
def test_get_tier_fallback_for_unknown():
"""Unknown tier ID should fall back to free."""
t = get_tier("nonexistent_tier")
assert t["id"] == "free"
@pytest.mark.unit
def test_get_all_tiers_returns_four():
tiers = get_all_tiers()
assert len(tiers) == 4
@pytest.mark.unit
def test_all_tiers_have_required_fields():
required = [
"id", "name", "tagline", "price_monthly", "price_yearly",
"lifetime_file_limit", "daily_upload_limit", "monthly_upload_limit",
"max_storage_destinations", "max_ocr_pages_monthly", "max_file_size_mb",
"features", "cta",
]
for tid, tier in TIERS.items():
for field in required:
assert field in tier, f"Tier '{tid}' missing field '{field}'"
@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
@pytest.mark.unit
def test_business_tier_is_unlimited():
"""Business tier must have 0 (unlimited) for all limits."""
t = TIERS["business"]
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
@pytest.mark.unit
def test_pricing_order():
"""Paid tier prices must increase in order: starter < professional < business."""
assert TIERS["starter"]["price_monthly"] < TIERS["professional"]["price_monthly"]
assert TIERS["professional"]["price_monthly"] < TIERS["business"]["price_monthly"]
# ---------------------------------------------------------------------------
# get_user_tier_id tests (mocked DB)
# ---------------------------------------------------------------------------
@pytest.mark.unit
def test_get_user_tier_id_returns_default_when_no_owner():
db = MagicMock()
assert get_user_tier_id(db, None) == DEFAULT_TIER
@pytest.mark.unit
def test_get_user_tier_id_returns_profile_tier():
db = MagicMock()
profile = MagicMock()
profile.subscription_tier = "professional"
db.query.return_value.filter.return_value.first.return_value = profile
assert get_user_tier_id(db, "user@example.com") == "professional"
@pytest.mark.unit
def test_get_user_tier_id_falls_back_to_free_when_no_profile():
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = None
assert get_user_tier_id(db, "unknown@example.com") == "free"
@pytest.mark.unit
def test_get_user_tier_id_falls_back_when_tier_is_none():
db = MagicMock()
profile = MagicMock()
profile.subscription_tier = None
db.query.return_value.filter.return_value.first.return_value = profile
assert get_user_tier_id(db, "user@example.com") == "free"
# ---------------------------------------------------------------------------
# check_upload_allowed tests (mocked DB)
# ---------------------------------------------------------------------------
@pytest.mark.unit
def test_check_upload_skipped_without_owner():
"""check_upload_allowed should not raise when owner_id is None."""
db = MagicMock()
check_upload_allowed(db, None, "free") # must not raise
@pytest.mark.unit
def test_check_upload_skipped_without_tier():
"""check_upload_allowed should not raise when tier_id is None."""
db = MagicMock()
check_upload_allowed(db, "user@example.com", None) # must not raise
@pytest.mark.unit
def test_check_upload_raises_when_lifetime_exceeded():
"""Free tier: should raise QuotaExceeded when lifetime limit is hit."""
db = MagicMock()
with patch("app.utils.subscription.get_lifetime_file_count", return_value=25):
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
@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):
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 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):
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 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):
with pytest.raises(QuotaExceeded) as exc_info:
check_upload_allowed(db, "user@example.com", "starter")
assert exc_info.value.limit_type == "monthly"
@pytest.mark.unit
def test_check_upload_business_tier_never_raises():
"""Business tier has no limits — check_upload_allowed must never raise."""
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):
check_upload_allowed(db, "user@example.com", "business") # must not raise
# ---------------------------------------------------------------------------
# get_user_usage (mocked DB)
# ---------------------------------------------------------------------------
@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):
result = get_user_usage(db, "user@example.com")
assert result == {"lifetime": 10, "today": 2, "month": 8}
# ---------------------------------------------------------------------------
# API: /api/subscriptions/tiers (integration-style, mocked app)
# ---------------------------------------------------------------------------
@pytest.mark.unit
def test_list_tiers_api(client):
"""GET /api/subscriptions/tiers must return all four tiers."""
resp = client.get("/api/subscriptions/tiers")
assert resp.status_code == 200
data = resp.json()
assert "tiers" in data
assert len(data["tiers"]) == 4
ids = [t["id"] for t in data["tiers"]]
assert "free" in ids
assert "starter" in ids
assert "professional" in ids
assert "business" in ids