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:
@@ -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.search import router as search_router
|
||||||
from app.api.settings import router as settings_router
|
from app.api.settings import router as settings_router
|
||||||
from app.api.similarity import router as similarity_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
|
from app.api.url_upload import router as url_upload_router
|
||||||
|
|
||||||
# Import all the individual routers
|
# Import all the individual routers
|
||||||
@@ -56,3 +57,4 @@ router.include_router(similarity_router)
|
|||||||
router.include_router(duplicates_router)
|
router.include_router(duplicates_router)
|
||||||
router.include_router(webhooks_router)
|
router.include_router(webhooks_router)
|
||||||
router.include_router(database_router)
|
router.include_router(database_router)
|
||||||
|
router.include_router(subscriptions_router)
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ class UserProfileUpsert(BaseModel):
|
|||||||
)
|
)
|
||||||
notes: str | None = Field(default=None, max_length=4096, description="Admin notes about this user")
|
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")
|
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):
|
class UserProfileResponse(BaseModel):
|
||||||
@@ -62,6 +66,7 @@ class UserProfileResponse(BaseModel):
|
|||||||
daily_upload_limit: int | None
|
daily_upload_limit: int | None
|
||||||
notes: str | None
|
notes: str | None
|
||||||
is_blocked: bool
|
is_blocked: bool
|
||||||
|
subscription_tier: str | None
|
||||||
created_at: str | None
|
created_at: str | None
|
||||||
updated_at: str | None
|
updated_at: str | None
|
||||||
|
|
||||||
@@ -76,6 +81,7 @@ class UserSummary(BaseModel):
|
|||||||
daily_upload_limit: int | None
|
daily_upload_limit: int | None
|
||||||
notes: str | None
|
notes: str | None
|
||||||
is_blocked: bool
|
is_blocked: bool
|
||||||
|
subscription_tier: str | None
|
||||||
profile_id: int | None
|
profile_id: int | None
|
||||||
document_count: int
|
document_count: int
|
||||||
last_upload: str | None
|
last_upload: str | None
|
||||||
@@ -99,6 +105,7 @@ def _profile_to_dict(profile: UserProfile) -> dict[str, Any]:
|
|||||||
"daily_upload_limit": profile.daily_upload_limit,
|
"daily_upload_limit": profile.daily_upload_limit,
|
||||||
"notes": profile.notes,
|
"notes": profile.notes,
|
||||||
"is_blocked": profile.is_blocked,
|
"is_blocked": profile.is_blocked,
|
||||||
|
"subscription_tier": profile.subscription_tier or "free",
|
||||||
"created_at": profile.created_at.isoformat() if profile.created_at else None,
|
"created_at": profile.created_at.isoformat() if profile.created_at else None,
|
||||||
"updated_at": profile.updated_at.isoformat() if profile.updated_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,
|
"daily_upload_limit": profile.daily_upload_limit if profile else None,
|
||||||
"notes": profile.notes if profile else None,
|
"notes": profile.notes if profile else None,
|
||||||
"is_blocked": profile.is_blocked if profile else False,
|
"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,
|
"profile_id": profile.id if profile else None,
|
||||||
"document_count": doc_row.doc_count if doc_row else 0,
|
"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,
|
"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,
|
"daily_upload_limit": profile.daily_upload_limit if profile else None,
|
||||||
"notes": profile.notes if profile else None,
|
"notes": profile.notes if profile else None,
|
||||||
"is_blocked": profile.is_blocked if profile else False,
|
"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,
|
"profile_id": profile.id if profile else None,
|
||||||
"document_count": doc_count,
|
"document_count": doc_count,
|
||||||
"last_upload": last_upload,
|
"last_upload": last_upload,
|
||||||
@@ -226,6 +235,15 @@ def upsert_user_profile(
|
|||||||
profile.daily_upload_limit = body.daily_upload_limit
|
profile.daily_upload_limit = body.daily_upload_limit
|
||||||
profile.notes = body.notes
|
profile.notes = body.notes
|
||||||
profile.is_blocked = body.is_blocked
|
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:
|
try:
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
+17
-1
@@ -11,7 +11,7 @@ import zipfile
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Annotated, List, Optional
|
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 fastapi.responses import StreamingResponse
|
||||||
from sqlalchemy import asc, desc
|
from sqlalchemy import asc, desc
|
||||||
from sqlalchemy.orm import Session
|
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
|
# Determine the owner_id for multi-user document isolation
|
||||||
upload_owner_id = get_current_owner_id(request) if settings.multi_user_enabled else None
|
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
|
# Check if it's a PDF by extension or MIME type
|
||||||
is_pdf = file_ext == ".pdf" or mime_type == "application/pdf"
|
is_pdf = file_ext == ".pdf" or mime_type == "application/pdf"
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
}
|
||||||
@@ -200,5 +200,9 @@ class UserProfile(Base):
|
|||||||
# When True the user is prevented from uploading new documents
|
# When True the user is prevented from uploading new documents
|
||||||
is_blocked = Column(Boolean, default=False, nullable=False)
|
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())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -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.search import router as search_router
|
||||||
from app.views.settings import router as settings_router
|
from app.views.settings import router as settings_router
|
||||||
from app.views.status import router as status_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
|
from app.views.wizard import router as wizard_router
|
||||||
|
|
||||||
# Create a main router that includes all the view routers
|
# 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(filemanager_router)
|
||||||
router.include_router(search_router)
|
router.include_router(search_router)
|
||||||
router.include_router(queue_router)
|
router.include_router(queue_router)
|
||||||
|
router.include_router(subscriptions_router) # Pricing + subscription pages
|
||||||
|
|||||||
+64
-12
@@ -2,11 +2,12 @@
|
|||||||
General routes for the application homepage and basic pages.
|
General routes for the application homepage and basic pages.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import date
|
from datetime import date, datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, Request
|
from fastapi import Depends, HTTPException, Request
|
||||||
from fastapi.responses import FileResponse, RedirectResponse
|
from fastapi.responses import FileResponse, RedirectResponse
|
||||||
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.utils.config_validator import get_provider_status, validate_storage_configs
|
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"]
|
and provider in ["dropbox", "nextcloud", "sftp", "s3", "ftp", "webdav", "google_drive", "onedrive"]
|
||||||
)
|
)
|
||||||
|
|
||||||
# Query the actual file count from the database
|
|
||||||
processed_files = 0
|
|
||||||
try:
|
|
||||||
# Import the model here to avoid circular imports
|
|
||||||
from app.models import FileRecord
|
from app.models import FileRecord
|
||||||
|
|
||||||
processed_files = db.query(FileRecord.id).count()
|
today = datetime.now(timezone.utc).date()
|
||||||
except Exception as e:
|
|
||||||
# Log error but continue (don't break the page if DB query fails)
|
# Global file counts (or per-user in multi-user mode)
|
||||||
logger.error(f"Error counting files: {str(e)}")
|
from app.config import settings
|
||||||
|
|
||||||
|
user = request.session.get("user") or {}
|
||||||
|
is_admin = user.get("is_admin", False)
|
||||||
|
|
||||||
|
try:
|
||||||
|
total_files: int = db.query(func.count(FileRecord.id)).scalar() or 0
|
||||||
|
|
||||||
|
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:
|
||||||
|
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 = {
|
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,
|
"active_integrations": configured_providers,
|
||||||
"storage_targets": configured_storage_targets,
|
"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)
|
@router.get("/about", include_in_schema=False)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -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).
|
||||||
@@ -141,25 +141,35 @@ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', fun
|
|||||||
if (authSection) {
|
if (authSection) {
|
||||||
authSection.textContent = ''; // Clear existing content
|
authSection.textContent = ''; // Clear existing content
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
container.className = 'flex items-center';
|
container.className = 'flex items-center gap-2';
|
||||||
|
|
||||||
const img = document.createElement('img');
|
const img = document.createElement('img');
|
||||||
img.src = data.picture;
|
img.src = data.picture;
|
||||||
img.alt = 'Avatar';
|
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');
|
const span = document.createElement('span');
|
||||||
span.textContent = displayName;
|
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');
|
const logoutLink = document.createElement('a');
|
||||||
logoutLink.href = '/logout';
|
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');
|
const icon = document.createElement('i');
|
||||||
icon.className = 'fas fa-sign-out-alt';
|
icon.className = 'fas fa-sign-out-alt';
|
||||||
logoutLink.appendChild(icon);
|
logoutLink.appendChild(icon);
|
||||||
|
|
||||||
container.appendChild(img);
|
container.appendChild(img);
|
||||||
container.appendChild(span);
|
container.appendChild(span);
|
||||||
|
container.appendChild(planLink);
|
||||||
container.appendChild(logoutLink);
|
container.appendChild(logoutLink);
|
||||||
authSection.appendChild(container);
|
authSection.appendChild(container);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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-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-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-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">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-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>
|
<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">
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
<template x-if="loading">
|
<template x-if="loading">
|
||||||
<tr>
|
<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…
|
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i> Loading users…
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!loading && users.length === 0">
|
<template x-if="!loading && users.length === 0">
|
||||||
<tr>
|
<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>
|
<i class="fas fa-users-slash text-3xl block mb-2" aria-hidden="true"></i>
|
||||||
No users found.
|
No users found.
|
||||||
<span x-show="search"> Try a different search term.</span>
|
<span x-show="search"> Try a different search term.</span>
|
||||||
@@ -117,6 +118,29 @@
|
|||||||
<!-- Last upload -->
|
<!-- Last upload -->
|
||||||
<td class="px-4 py-3 text-sm text-gray-500 whitespace-nowrap"
|
<td class="px-4 py-3 text-sm text-gray-500 whitespace-nowrap"
|
||||||
x-text="user.last_upload ? formatDate(user.last_upload) : '—'"></td>
|
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 -->
|
<!-- Upload limit -->
|
||||||
<td class="px-4 py-3 text-sm text-center text-gray-700">
|
<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"
|
<span x-show="user.daily_upload_limit !== null && user.daily_upload_limit !== undefined"
|
||||||
@@ -300,6 +324,26 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
@@ -394,6 +438,7 @@ function adminUsersApp() {
|
|||||||
daily_upload_limit: null,
|
daily_upload_limit: null,
|
||||||
notes: '',
|
notes: '',
|
||||||
is_blocked: false,
|
is_blocked: false,
|
||||||
|
subscription_tier: 'free',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Delete modal
|
// Delete modal
|
||||||
@@ -446,7 +491,7 @@ function adminUsersApp() {
|
|||||||
openCreateModal() {
|
openCreateModal() {
|
||||||
this.isCreate = true;
|
this.isCreate = true;
|
||||||
this.modalTitle = 'Add User Profile';
|
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;
|
this.modalOpen = true;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -460,6 +505,7 @@ function adminUsersApp() {
|
|||||||
? user.daily_upload_limit : null,
|
? user.daily_upload_limit : null,
|
||||||
notes: user.notes || '',
|
notes: user.notes || '',
|
||||||
is_blocked: !!user.is_blocked,
|
is_blocked: !!user.is_blocked,
|
||||||
|
subscription_tier: user.subscription_tier || 'free',
|
||||||
};
|
};
|
||||||
this.modalOpen = true;
|
this.modalOpen = true;
|
||||||
},
|
},
|
||||||
@@ -473,6 +519,7 @@ function adminUsersApp() {
|
|||||||
? Number(this.form.daily_upload_limit) : null,
|
? Number(this.form.daily_upload_limit) : null,
|
||||||
notes: this.form.notes || null,
|
notes: this.form.notes || null,
|
||||||
is_blocked: !!this.form.is_blocked,
|
is_blocked: !!this.form.is_blocked,
|
||||||
|
subscription_tier: this.form.subscription_tier || 'free',
|
||||||
};
|
};
|
||||||
const uid = encodeURIComponent(this.form.user_id);
|
const uid = encodeURIComponent(this.form.user_id);
|
||||||
const resp = await fetch(`/api/admin/users/${uid}`, {
|
const resp = await fetch(`/api/admin/users/${uid}`, {
|
||||||
|
|||||||
@@ -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="/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="/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="/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) -->
|
<!-- Admin dropdown (shown only for admin users via JS) -->
|
||||||
<div id="adminMenuContainer" class="relative hidden">
|
<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="/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="/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="/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) -->
|
<!-- Admin section in mobile menu (shown only for admin users via JS) -->
|
||||||
<div id="mobileAdminSection" class="hidden">
|
<div id="mobileAdminSection" class="hidden">
|
||||||
|
|||||||
@@ -4,6 +4,302 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container mx-auto px-4 py-8">
|
<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 & 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 & 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 & 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 & 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 -->
|
<!-- 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="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 class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
@@ -154,6 +450,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% endif %}{# end multi_user_enabled #}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user