Merge pull request #490 from christianlouis/copilot/add-subscription-page

fix: add subscription_overage_percent to SETTING_METADATA and document in ConfigurationGuide/.env.demo
This commit is contained in:
Christian Krakau-Louis
2026-03-07 10:59:01 +01:00
committed by GitHub
28 changed files with 3600 additions and 22 deletions
+6
View File
@@ -141,6 +141,12 @@ UNOWNED_DOCS_VISIBLE_TO_ALL=true
# Leave empty/unset to keep them unowned until claimed.
# DEFAULT_OWNER_ID=
# **Subscription / Quota Settings**
# Soft-limit overage buffer in percent (0200). Announced quota is multiplied by (1 + percent/100)
# for actual enforcement. E.g. 20 means a 150-doc/month plan enforces at 180. 0 = enforce exactly.
# Per-plan overage_percent set in the Plan Designer overrides this global default.
# SUBSCRIPTION_OVERAGE_PERCENT=20
# **OpenID Connect/Authentik Settings**
AUTHENTIK_CLIENT_ID=<yourAuthentikAppClientID>
AUTHENTIK_CLIENT_SECRET=<yourAuthentikClientSecret>
+4
View File
@@ -17,12 +17,14 @@ from app.api.google_drive import router as google_drive_router
from app.api.logs import router as logs_router
from app.api.onedrive import router as onedrive_router
from app.api.openai import router as openai_router
from app.api.plans import router as plans_router
from app.api.process import router as process_router
from app.api.queue import router as queue_router
from app.api.saved_searches import router as saved_searches_router
from app.api.search import router as search_router
from app.api.settings import router as settings_router
from app.api.similarity import router as similarity_router
from app.api.subscriptions import router as subscriptions_router
from app.api.url_upload import router as url_upload_router
# Import all the individual routers
@@ -56,3 +58,5 @@ router.include_router(similarity_router)
router.include_router(duplicates_router)
router.include_router(webhooks_router)
router.include_router(database_router)
router.include_router(subscriptions_router)
router.include_router(plans_router)
+48
View File
@@ -5,6 +5,7 @@ administrators can inspect, configure, and manage users in multi-user mode.
"""
import logging
from datetime import datetime
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
@@ -51,6 +52,13 @@ class UserProfileUpsert(BaseModel):
)
notes: str | None = Field(default=None, max_length=4096, description="Admin notes about this user")
is_blocked: bool = Field(default=False, description="Block this user from uploading")
subscription_tier: str | None = Field(
default="free",
description="Subscription tier: free | starter | professional | business",
)
subscription_billing_cycle: str = Field(default="monthly", pattern="^(monthly|yearly)$")
subscription_period_start: datetime | None = None
allow_overage: bool = False
class UserProfileResponse(BaseModel):
@@ -62,6 +70,10 @@ class UserProfileResponse(BaseModel):
daily_upload_limit: int | None
notes: str | None
is_blocked: bool
subscription_tier: str | None
subscription_billing_cycle: str
subscription_period_start: str | None
allow_overage: bool
created_at: str | None
updated_at: str | None
@@ -76,6 +88,10 @@ class UserSummary(BaseModel):
daily_upload_limit: int | None
notes: str | None
is_blocked: bool
subscription_tier: str | None
subscription_billing_cycle: str | None
subscription_period_start: str | None
allow_overage: bool
profile_id: int | None
document_count: int
last_upload: str | None
@@ -99,6 +115,12 @@ def _profile_to_dict(profile: UserProfile) -> dict[str, Any]:
"daily_upload_limit": profile.daily_upload_limit,
"notes": profile.notes,
"is_blocked": profile.is_blocked,
"subscription_tier": profile.subscription_tier or "free",
"subscription_billing_cycle": profile.subscription_billing_cycle or "monthly",
"subscription_period_start": profile.subscription_period_start.isoformat()
if profile.subscription_period_start
else None,
"allow_overage": bool(profile.allow_overage),
"created_at": profile.created_at.isoformat() if profile.created_at else None,
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
}
@@ -164,6 +186,14 @@ def list_users(
"daily_upload_limit": profile.daily_upload_limit if profile else None,
"notes": profile.notes if profile else None,
"is_blocked": profile.is_blocked if profile else False,
"subscription_tier": (profile.subscription_tier or "free") if profile else "free",
"subscription_billing_cycle": (profile.subscription_billing_cycle or "monthly")
if profile
else "monthly",
"subscription_period_start": profile.subscription_period_start.isoformat()
if (profile and profile.subscription_period_start)
else None,
"allow_overage": bool(profile.allow_overage) if profile else False,
"profile_id": profile.id if profile else None,
"document_count": doc_row.doc_count if doc_row else 0,
"last_upload": doc_row.last_upload.isoformat() if (doc_row and doc_row.last_upload) else None,
@@ -199,6 +229,12 @@ def get_user(user_id: str, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
"daily_upload_limit": profile.daily_upload_limit if profile else None,
"notes": profile.notes if profile else None,
"is_blocked": profile.is_blocked if profile else False,
"subscription_tier": (profile.subscription_tier or "free") if profile else "free",
"subscription_billing_cycle": (profile.subscription_billing_cycle or "monthly") if profile else "monthly",
"subscription_period_start": profile.subscription_period_start.isoformat()
if (profile and profile.subscription_period_start)
else None,
"allow_overage": bool(profile.allow_overage) if profile else False,
"profile_id": profile.id if profile else None,
"document_count": doc_count,
"last_upload": last_upload,
@@ -226,6 +262,18 @@ def upsert_user_profile(
profile.daily_upload_limit = body.daily_upload_limit
profile.notes = body.notes
profile.is_blocked = body.is_blocked
profile.subscription_billing_cycle = body.subscription_billing_cycle
profile.subscription_period_start = body.subscription_period_start
profile.allow_overage = body.allow_overage
if body.subscription_tier is not None:
from app.utils.subscription import TIERS
if body.subscription_tier not in TIERS:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid subscription_tier '{body.subscription_tier}'. Valid values: {list(TIERS.keys())}",
)
profile.subscription_tier = body.subscription_tier
try:
db.commit()
+18 -4
View File
@@ -11,7 +11,7 @@ import zipfile
from datetime import datetime, timezone
from typing import Annotated, List, Optional
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile, status
from fastapi.responses import StreamingResponse
from sqlalchemy import asc, desc
from sqlalchemy.orm import Session
@@ -1256,6 +1256,23 @@ async def ui_upload(request: Request, db: DbSession, file: UploadFile = File(...
# Store both the safe original name and the unique name
target_path = os.path.join(workdir, target_filename)
# Determine the owner_id for multi-user document isolation
upload_owner_id = get_current_owner_id(request) if settings.multi_user_enabled else None
# Enforce subscription tier upload quotas (multi-user mode only) BEFORE writing the file
# so that users who have exceeded their quota do not waste bandwidth or disk I/O.
if settings.multi_user_enabled and upload_owner_id:
from app.utils.subscription import QuotaExceeded, check_upload_allowed, get_user_tier_id
tier_id = get_user_tier_id(db, upload_owner_id)
try:
check_upload_allowed(db, upload_owner_id, tier_id)
except QuotaExceeded as qe:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail=str(qe),
)
# Read file in chunks to avoid loading the entire body into memory at once,
# enforcing the size limit during the read so memory usage stays bounded.
try:
@@ -1292,9 +1309,6 @@ async def ui_upload(request: Request, db: DbSession, file: UploadFile = File(...
mime_type, _ = mimetypes.guess_type(target_path)
file_ext = os.path.splitext(target_path)[1].lower()
# Determine the owner_id for multi-user document isolation
upload_owner_id = get_current_owner_id(request) if settings.multi_user_enabled else None
# Check if it's a PDF by extension or MIME type
is_pdf = file_ext == ".pdf" or mime_type == "application/pdf"
+273
View File
@@ -0,0 +1,273 @@
"""REST API for subscription plan CRUD.
Endpoints:
GET /api/plans/ — list active plans (public)
GET /api/plans/admin — list all plans inc. inactive (admin only)
POST /api/plans/ — create plan (admin only)
GET /api/plans/{plan_id} — get single active plan (public)
PUT /api/plans/{plan_id} — update plan (admin only)
DELETE /api/plans/{plan_id} — delete plan (admin only)
POST /api/plans/seed — seed default plans (admin only)
POST /api/plans/reorder — set sort_order for multiple plans (admin only)
"""
import json
import logging
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import SubscriptionPlan
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/plans", tags=["plans"])
DbSession = Annotated[Session, Depends(get_db)]
# ---------------------------------------------------------------------------
# Auth helper (admin-only)
# ---------------------------------------------------------------------------
def _require_admin(request: Request) -> dict:
"""Ensure the caller is an admin. Raises 403 otherwise."""
user = request.session.get("user")
if not user or not user.get("is_admin"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return user
AdminUser = Annotated[dict, Depends(_require_admin)]
# ---------------------------------------------------------------------------
# Pydantic schemas
# ---------------------------------------------------------------------------
class PlanUpsert(BaseModel):
"""Body for creating or updating a subscription plan."""
name: str
tagline: str | None = None
price_monthly: float = 0.0
price_yearly: float = 0.0
trial_days: int = 0
lifetime_file_limit: int = 0
daily_upload_limit: int = 0
monthly_upload_limit: int = 0
max_storage_destinations: int = 0
max_ocr_pages_monthly: int = 0
max_file_size_mb: int = 0
max_mailboxes: int = 0
overage_percent: int = Field(default=20, ge=0, le=200)
allow_overage_billing: bool = False
overage_price_per_doc: float | None = None
overage_price_per_ocr_page: float | None = None
is_active: bool = True
is_highlighted: bool = False
badge_text: str | None = None
cta_text: str = "Get started"
sort_order: int = 0
features: list[str] = []
api_access: bool = False
class ReorderBody(BaseModel):
"""Body for reordering plans."""
order: list[str]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _plan_to_response(plan: SubscriptionPlan) -> dict[str, Any]:
features: list[str] = []
if plan.features:
try:
features = json.loads(plan.features)
except (json.JSONDecodeError, TypeError):
features = []
return {
"id": plan.id,
"plan_id": plan.plan_id,
"name": plan.name,
"tagline": plan.tagline,
"price_monthly": plan.price_monthly,
"price_yearly": plan.price_yearly,
"trial_days": plan.trial_days,
"lifetime_file_limit": plan.lifetime_file_limit,
"daily_upload_limit": plan.daily_upload_limit,
"monthly_upload_limit": plan.monthly_upload_limit,
"max_storage_destinations": plan.max_storage_destinations,
"max_ocr_pages_monthly": plan.max_ocr_pages_monthly,
"max_file_size_mb": plan.max_file_size_mb,
"max_mailboxes": plan.max_mailboxes,
"overage_percent": plan.overage_percent,
"allow_overage_billing": plan.allow_overage_billing,
"overage_price_per_doc": plan.overage_price_per_doc,
"overage_price_per_ocr_page": plan.overage_price_per_ocr_page,
"is_active": plan.is_active,
"is_highlighted": plan.is_highlighted,
"badge_text": plan.badge_text,
"cta_text": plan.cta_text,
"sort_order": plan.sort_order,
"features": features,
"api_access": plan.api_access,
"created_at": plan.created_at.isoformat() if plan.created_at else None,
"updated_at": plan.updated_at.isoformat() if plan.updated_at else None,
}
def _apply_body(plan: SubscriptionPlan, body: PlanUpsert) -> None:
"""Apply PlanUpsert fields onto a SubscriptionPlan ORM object."""
plan.name = body.name
plan.tagline = body.tagline
plan.price_monthly = body.price_monthly
plan.price_yearly = body.price_yearly
plan.trial_days = body.trial_days
plan.lifetime_file_limit = body.lifetime_file_limit
plan.daily_upload_limit = body.daily_upload_limit
plan.monthly_upload_limit = body.monthly_upload_limit
plan.max_storage_destinations = body.max_storage_destinations
plan.max_ocr_pages_monthly = body.max_ocr_pages_monthly
plan.max_file_size_mb = body.max_file_size_mb
plan.max_mailboxes = body.max_mailboxes
plan.overage_percent = body.overage_percent
plan.allow_overage_billing = body.allow_overage_billing
plan.overage_price_per_doc = body.overage_price_per_doc
plan.overage_price_per_ocr_page = body.overage_price_per_ocr_page
plan.is_active = body.is_active
plan.is_highlighted = body.is_highlighted
plan.badge_text = body.badge_text
plan.cta_text = body.cta_text
plan.sort_order = body.sort_order
plan.features = json.dumps(body.features)
plan.api_access = body.api_access
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("/", summary="List active plans (public)")
def list_active_plans(db: DbSession) -> dict[str, Any]:
"""Return all active plans in sort order. Public endpoint — no auth required."""
plans = (
db.query(SubscriptionPlan)
.filter(SubscriptionPlan.is_active.is_(True))
.order_by(SubscriptionPlan.sort_order)
.all()
)
return {"plans": [_plan_to_response(p) for p in plans]}
@router.get("/admin", summary="List all plans including inactive (admin only)")
def list_all_plans(db: DbSession, _admin: AdminUser) -> dict[str, Any]:
"""Return all plans (active and inactive) in sort order. Admin only."""
plans = db.query(SubscriptionPlan).order_by(SubscriptionPlan.sort_order).all()
return {"plans": [_plan_to_response(p) for p in plans]}
@router.post("/seed", summary="Seed default plans (admin only)", status_code=status.HTTP_200_OK)
def seed_plans(db: DbSession, _admin: AdminUser) -> dict[str, Any]:
"""Seed the subscription_plans table from TIER_DEFAULTS. No-op if plans already exist."""
from app.utils.subscription import seed_default_plans
inserted = seed_default_plans(db)
return {"inserted": inserted, "message": f"Seeded {inserted} default plan(s)."}
@router.post("/reorder", summary="Reorder plans (admin only)")
def reorder_plans(body: ReorderBody, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
"""Update sort_order for each plan_id in *body.order* (position = index in list)."""
updated = 0
for sort_order, plan_id in enumerate(body.order):
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.plan_id == plan_id).first()
if plan:
plan.sort_order = sort_order
updated += 1
try:
db.commit()
except Exception:
db.rollback()
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to reorder plans")
return {"updated": updated}
@router.post("/", summary="Create a new plan (admin only)", status_code=status.HTTP_201_CREATED)
def create_plan(plan_id: str, body: PlanUpsert, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
"""Create a new subscription plan with the given *plan_id* slug."""
existing = db.query(SubscriptionPlan).filter(SubscriptionPlan.plan_id == plan_id).first()
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Plan '{plan_id}' already exists.",
)
plan = SubscriptionPlan(plan_id=plan_id)
_apply_body(plan, body)
db.add(plan)
try:
db.commit()
db.refresh(plan)
except Exception:
db.rollback()
raise
logger.info("Admin created subscription plan '%s'", plan_id)
return _plan_to_response(plan)
@router.get("/{plan_id}", summary="Get a single active plan (public)")
def get_plan(plan_id: str, db: DbSession) -> dict[str, Any]:
"""Return a single active plan by plan_id. Public endpoint."""
plan = (
db.query(SubscriptionPlan)
.filter(
SubscriptionPlan.plan_id == plan_id,
SubscriptionPlan.is_active.is_(True),
)
.first()
)
if not plan:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Plan '{plan_id}' not found.")
return _plan_to_response(plan)
@router.put("/{plan_id}", summary="Update an existing plan (admin only)")
def update_plan(plan_id: str, body: PlanUpsert, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
"""Update an existing subscription plan. Admin only."""
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.plan_id == plan_id).first()
if not plan:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Plan '{plan_id}' not found.")
_apply_body(plan, body)
try:
db.commit()
db.refresh(plan)
except Exception:
db.rollback()
raise
logger.info("Admin updated subscription plan '%s'", plan_id)
return _plan_to_response(plan)
@router.delete("/{plan_id}", summary="Delete a plan (admin only)", status_code=status.HTTP_204_NO_CONTENT)
def delete_plan(plan_id: str, db: DbSession, _admin: AdminUser) -> None:
"""Delete a subscription plan. Admin only."""
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.plan_id == plan_id).first()
if not plan:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Plan '{plan_id}' not found.")
try:
db.delete(plan)
db.commit()
except Exception:
db.rollback()
raise
logger.info("Admin deleted subscription plan '%s'", plan_id)
+137
View File
@@ -0,0 +1,137 @@
"""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.api.admin_users import _require_admin
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)]
AdminUser = Annotated[dict, Depends(_require_admin)]
# ---------------------------------------------------------------------------
# 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 = request.session.get("user")
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, _admin: AdminUser) -> dict[str, Any]:
"""Return aggregate statistics across all users and tiers (admin only)."""
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(),
}
+13
View File
@@ -153,6 +153,19 @@ class Settings(BaseSettings):
),
)
subscription_overage_percent: int = Field(
default=20,
ge=0,
le=200,
description=(
"Soft-limit overage buffer in percent (0200). The announced monthly quota is "
"increased by this percentage for actual enforcement. E.g. 20 means a 150-doc/month "
"plan enforces at 180 docs (150 × 1.20). Set 0 to enforce exactly at the announced "
"limit. Per-plan overage_percent (set in Plan Designer) overrides this global default. "
"Default: 20."
),
)
# Authentik
authentik_client_id: Optional[str] = None
authentik_client_secret: Optional[str] = None
+13
View File
@@ -99,6 +99,19 @@ async def lifespan(app: FastAPI):
# Send startup notification
notify_startup()
# Seed default subscription plans if none exist
try:
from app.database import SessionLocal as _SessionLocal
from app.utils.subscription import seed_default_plans as _seed_plans
_db_seed = _SessionLocal()
try:
_seed_plans(_db_seed)
finally:
_db_seed.close()
except Exception:
logging.debug("Subscription plan seeding skipped — DB may not be ready yet") # noqa: S110
# Application is now running
yield
+58 -1
View File
@@ -1,6 +1,6 @@
# app/models.py
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
from sqlalchemy import Boolean, Column, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
from app.database import Base
@@ -200,5 +200,62 @@ class UserProfile(Base):
# When True the user is prevented from uploading new documents
is_blocked = Column(Boolean, default=False, nullable=False)
# Subscription tier: "free" | "starter" | "professional" | "business"
# NULL is treated as "free" by the subscription utility.
subscription_tier = Column(String(50), nullable=True, default="free")
# Billing cycle and overage settings (added in migration 016)
subscription_billing_cycle = Column(String(10), nullable=False, default="monthly", server_default="monthly")
subscription_period_start = Column(DateTime(timezone=True), nullable=True)
allow_overage = Column(Boolean, nullable=False, default=False, server_default="0")
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class SubscriptionPlan(Base):
"""Dynamically configurable subscription plan stored in the database.
Plans are shown on the public /pricing page and assigned to users via
UserProfile.subscription_tier (which stores plan_id). On first start the
four default plans are seeded from TIER_DEFAULTS in app/utils/subscription.py.
"""
__tablename__ = "subscription_plans"
id = Column(Integer, primary_key=True, index=True)
plan_id = Column(String(50), unique=True, nullable=False, index=True)
name = Column(String(100), nullable=False)
tagline = Column(String(255), nullable=True)
# Pricing
price_monthly = Column(Float, nullable=False, default=0.0)
price_yearly = Column(Float, nullable=False, default=0.0)
trial_days = Column(Integer, nullable=False, default=0)
# Volume limits (0 = unlimited)
lifetime_file_limit = Column(Integer, nullable=False, default=0)
daily_upload_limit = Column(Integer, nullable=False, default=0)
monthly_upload_limit = Column(Integer, nullable=False, default=0)
max_storage_destinations = Column(Integer, nullable=False, default=0)
max_ocr_pages_monthly = Column(Integer, nullable=False, default=0)
max_file_size_mb = Column(Integer, nullable=False, default=0)
max_mailboxes = Column(Integer, nullable=False, default=0)
# Overage configuration
overage_percent = Column(Integer, nullable=False, default=20)
allow_overage_billing = Column(Boolean, nullable=False, default=False)
overage_price_per_doc = Column(Float, nullable=True)
overage_price_per_ocr_page = Column(Float, nullable=True)
# Display / marketing
is_active = Column(Boolean, nullable=False, default=True)
is_highlighted = Column(Boolean, nullable=False, default=False)
badge_text = Column(String(50), nullable=True)
cta_text = Column(String(100), nullable=False, default="Get started")
sort_order = Column(Integer, nullable=False, default=0)
features = Column(Text, nullable=True) # JSON-encoded list[str]
api_access = Column(Boolean, nullable=False, default=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
+13
View File
@@ -1771,6 +1771,19 @@ SETTING_METADATA = {
"required": False,
"restart_required": True,
},
"subscription_overage_percent": {
"category": "Subscriptions",
"description": (
"Soft-limit overage buffer in percent (0200). The announced monthly quota is increased by this "
"percentage for actual enforcement. For example, 20 means a 150-doc/month plan enforces at 180 docs "
"(150 × 1.20). Set to 0 to enforce exactly at the announced limit. Per-plan overage_percent configured "
"in the Plan Designer overrides this global default. Default: 20."
),
"type": "integer",
"sensitive": False,
"required": False,
"restart_required": False,
},
}
+500
View File
@@ -0,0 +1,500 @@
"""
Subscription tier definitions and enforcement utilities for DocuElevate SaaS.
Four tiers (prices ex-VAT; German customers +19 % MwSt):
- free $0/mo — 50 lifetime docs, 150 lifetime OCR pages, 1 dest
- starter $2.99/mo — 50/mo, 300 OCR pp/mo, 2 dests, 1 mailbox
- professional $5.99/mo — 150/mo, 750 OCR pp/mo, 5 dests, 3 mailboxes
- business $7.99/mo — 300/mo, 1500 OCR pp/mo, 10 dests, unlimited mailboxes
Limits use 0 to represent "unlimited".
All paid tiers include a 30-day free trial (trial_days field).
--- Cost analysis at maximum usage (Hetzner Option-A infra, Azure Read + GPT-4o mini) ---
Infrastructure: CX32 (app+Redis €7.59) + CX22 (worker €3.79) + BX21 (storage €7.22) ≈ $24/mo
At 100 users infra share ≈ $0.24/user/mo.
Starter : OCR $0.45 + AI $0.012 + infra $0.24 + Stripe $0.34 = $1.04 → 65 % gross margin
Professional: OCR $1.13 + AI $0.035 + infra $0.24 + Stripe $0.42 = $1.82 → 70 % gross margin
Business : OCR $2.25 + AI $0.069 + infra $0.24 + Stripe $0.48 = $3.04 → 62 % gross margin
After ~30 % German corporate tax: Starter 45 %, Professional 49 %, Business 43 %.
At average usage (~40 % of quota) margins improve to 55-65 % after tax.
⚠ If GPT-4o (not mini) is configured, Business AI cost at max rises to ~$1.92/user,
reducing after-tax margin to ~33 %. Recommend GPT-4o mini as default in production.
"""
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
from app.config import settings
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Tier catalogue
# ---------------------------------------------------------------------------
TIER_DEFAULTS: dict[str, dict[str, Any]] = {
"free": {
"id": "free",
"name": "Free",
"tagline": "Explore DocuElevate at no cost",
"price_monthly": 0,
"price_yearly": 0,
"trial_days": 0,
"highlight": False,
# Hard caps — 0 = unlimited
"lifetime_file_limit": 50, # total docs ever processed (enforced at upload)
"daily_upload_limit": 0, # no per-day cap (lifetime cap applies instead)
"monthly_upload_limit": 0, # no per-month cap (lifetime cap applies instead)
"max_storage_destinations": 1,
"max_ocr_pages_monthly": 150, # informational; enforced when OCR quota tracking lands
"max_file_size_mb": 5,
"max_mailboxes": 0, # no email ingestion on free tier
"api_access": False,
# Marketing feature list (shown on pricing page)
"features": [
"50 documents — lifetime total",
"150 OCR pages — lifetime total",
"1 storage destination",
"5 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 getting started",
"price_monthly": 2.99,
"price_yearly": 28.99, # ≈ 80 % of monthly × 12 — save ~19 % (≈ 2½ months free)
"trial_days": 30,
"highlight": False,
"lifetime_file_limit": 0,
"daily_upload_limit": 0, # no daily cap
"monthly_upload_limit": 50,
"max_storage_destinations": 2,
"max_ocr_pages_monthly": 300,
"max_file_size_mb": 25,
"max_mailboxes": 1,
"api_access": True,
"features": [
"50 documents / month",
"2 storage destinations",
"300 OCR pages / month",
"25 MB max file size",
"Full AI metadata extraction",
"1 email ingestion mailbox",
"API access",
"Email support",
],
"cta": "Start free trial",
"badge": None,
},
"professional": {
"id": "professional",
"name": "Professional",
"tagline": "For growing teams that need more power",
"price_monthly": 5.99,
"price_yearly": 57.99, # ≈ 80 % of monthly × 12 — save ~19 %
"trial_days": 30,
"highlight": True, # shown as "Most popular"
"lifetime_file_limit": 0,
"daily_upload_limit": 0, # no daily cap
"monthly_upload_limit": 150,
"max_storage_destinations": 5,
"max_ocr_pages_monthly": 750,
"max_file_size_mb": 100,
"max_mailboxes": 3,
"api_access": True,
"features": [
"150 documents / month",
"5 storage destinations",
"750 OCR pages / month",
"100 MB max file size",
"Advanced AI workflows",
"3 email ingestion mailboxes",
"Email & URL ingestion",
"Webhooks",
"Priority email support",
],
"cta": "Start free trial",
"badge": "Most Popular",
},
"business": {
"id": "business",
"name": "Business",
"tagline": "High-volume processing for organisations",
"price_monthly": 7.99,
"price_yearly": 76.99, # ≈ 80 % of monthly × 12 — save ~20 %
"trial_days": 30,
"highlight": False,
"lifetime_file_limit": 0,
"daily_upload_limit": 0, # no daily cap
"monthly_upload_limit": 300,
"max_storage_destinations": 10,
"max_ocr_pages_monthly": 1500,
"max_file_size_mb": 0, # unlimited file size
"max_mailboxes": 0, # unlimited mailboxes
"api_access": True,
"features": [
"300 documents / month",
"10 storage destinations",
"1,500 OCR pages / month",
"Unlimited file size",
"All AI processing steps",
"Unlimited email ingestion mailboxes",
"All ingestion methods",
"Webhooks & full API access",
"Dedicated support",
],
"cta": "Start free trial",
"badge": "Best Value",
},
}
# Backward-compatible alias
TIERS = TIER_DEFAULTS
# Display order for the pricing page
TIER_ORDER = ["free", "starter", "professional", "business"]
# Default tier assigned to new users
DEFAULT_TIER = "free"
# ---------------------------------------------------------------------------
# DB → dict conversion
# ---------------------------------------------------------------------------
def _plan_to_dict(plan: Any) -> dict[str, Any]:
"""Convert a SubscriptionPlan ORM object to the same dict shape as TIER_DEFAULTS entries."""
import json
features: list[str] = []
if plan.features:
try:
features = json.loads(plan.features)
except (json.JSONDecodeError, TypeError):
features = []
return {
"id": plan.plan_id,
"name": plan.name,
"tagline": plan.tagline or "",
"price_monthly": plan.price_monthly,
"price_yearly": plan.price_yearly,
"trial_days": plan.trial_days,
"highlight": plan.is_highlighted,
"lifetime_file_limit": plan.lifetime_file_limit,
"daily_upload_limit": plan.daily_upload_limit,
"monthly_upload_limit": plan.monthly_upload_limit,
"max_storage_destinations": plan.max_storage_destinations,
"max_ocr_pages_monthly": plan.max_ocr_pages_monthly,
"max_file_size_mb": plan.max_file_size_mb,
"max_mailboxes": plan.max_mailboxes,
"api_access": plan.api_access,
"features": features,
"cta": plan.cta_text or "Get started",
"badge": plan.badge_text,
"overage_percent": plan.overage_percent,
"allow_overage_billing": plan.allow_overage_billing,
}
# ---------------------------------------------------------------------------
# Getters
# ---------------------------------------------------------------------------
def get_tier(tier_id: str, db: Session | None = None) -> dict[str, Any]:
"""Return plan config dict; DB-first when db is provided, falls back to TIER_DEFAULTS."""
if db is not None:
from app.models import SubscriptionPlan
plan = (
db.query(SubscriptionPlan)
.filter(
SubscriptionPlan.plan_id == tier_id,
SubscriptionPlan.is_active.is_(True),
)
.first()
)
if plan is not None:
return _plan_to_dict(plan)
return TIER_DEFAULTS.get(tier_id, TIER_DEFAULTS["free"])
def get_all_tiers(db: Session | None = None) -> list[dict[str, Any]]:
"""Return plans in display order; DB-first when db is provided."""
if db is not None:
from app.models import SubscriptionPlan
plans = (
db.query(SubscriptionPlan)
.filter(SubscriptionPlan.is_active.is_(True))
.order_by(SubscriptionPlan.sort_order)
.all()
)
if plans:
return [_plan_to_dict(p) for p in plans]
return [TIER_DEFAULTS[tid] for tid in TIER_ORDER]
def seed_default_plans(db: Session) -> int:
"""Seed subscription_plans table from TIER_DEFAULTS if the table is empty.
Called at application startup. Returns the number of plans inserted (0 if already seeded).
"""
import json
from app.models import SubscriptionPlan
try:
if db.query(SubscriptionPlan).count() > 0:
return 0
except Exception:
return 0 # table may not exist yet during first migration
inserted = 0
for sort_order, (_, tier) in enumerate(TIER_DEFAULTS.items()):
plan = SubscriptionPlan(
plan_id=tier["id"],
name=tier["name"],
tagline=tier.get("tagline", ""),
price_monthly=tier["price_monthly"],
price_yearly=tier["price_yearly"],
trial_days=tier.get("trial_days", 0),
is_highlighted=tier.get("highlight", False),
badge_text=tier.get("badge"),
cta_text=tier.get("cta", "Get started"),
lifetime_file_limit=tier["lifetime_file_limit"],
daily_upload_limit=tier["daily_upload_limit"],
monthly_upload_limit=tier["monthly_upload_limit"],
max_storage_destinations=tier["max_storage_destinations"],
max_ocr_pages_monthly=tier["max_ocr_pages_monthly"],
max_file_size_mb=tier["max_file_size_mb"],
max_mailboxes=tier.get("max_mailboxes", 0),
api_access=tier.get("api_access", False),
features=json.dumps(tier.get("features", [])),
overage_percent=20,
allow_overage_billing=False,
sort_order=sort_order,
is_active=True,
)
db.add(plan)
inserted += 1
try:
db.commit()
logger.info("Seeded %d default subscription plans", inserted)
except Exception as exc:
db.rollback()
logger.error("Failed to seed subscription plans: %s", exc)
inserted = 0
return inserted
# ---------------------------------------------------------------------------
# Usage queries
# ---------------------------------------------------------------------------
def _today_utc() -> date:
return datetime.now(timezone.utc).date()
def _scalar_count(query: Any) -> int:
"""Execute a count query and return an int, defaulting to 0 for NULL."""
return query.scalar() or 0
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 _scalar_count(
db.query(func.count(FileRecord.id)).filter(FileRecord.owner_id == owner_id, FileRecord.is_duplicate.is_(False))
)
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 _scalar_count(
db.query(func.count(FileRecord.id)).filter(
FileRecord.owner_id == owner_id,
FileRecord.is_duplicate.is_(False),
func.date(FileRecord.created_at) == today,
)
)
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 _scalar_count(
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"),
)
)
def get_year_file_count(db: Session, owner_id: str, period_start: datetime) -> int:
"""Files processed since the start of the current annual subscription period."""
from app.models import FileRecord
return _scalar_count(
db.query(func.count(FileRecord.id)).filter(
FileRecord.owner_id == owner_id,
FileRecord.is_duplicate.is_(False),
FileRecord.created_at >= period_start,
)
)
def _months_elapsed(period_start: datetime, now: datetime) -> int:
"""Calendar months elapsed since *period_start*, clamped to [1, 12]."""
elapsed = (now.year - period_start.year) * 12 + (now.month - period_start.month) + 1
return max(1, min(elapsed, 12))
# ---------------------------------------------------------------------------
# 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.
Skipped entirely when *owner_id* or *tier_id* is ``None`` (single-user mode).
Enforcement model
-----------------
* **Announced limit** — the quota shown on the pricing page
(``monthly_upload_limit`` in the plan).
* **Overage buffer** — each plan stores ``overage_percent`` (default 20).
Enforcement = announced × (1 + overage_percent / 100). A 150-doc/month
plan with 20 % buffer is enforced at 180 docs.
* **Overage flag** — if ``UserProfile.allow_overage`` is ``True``, quota
checks are bypassed entirely so usage can be billed retroactively.
(Not yet exposed in the admin UI — baked in for future billing.)
* **Yearly carry-over** — yearly subscribers have cumulative quota:
effective limit = monthly_limit × months_elapsed × overage_factor.
Unused quota from earlier months rolls forward automatically.
* **No daily cap** — ``daily_upload_limit`` is kept for display purposes
only; it is never enforced.
"""
if owner_id is None or tier_id is None:
return
tier = get_tier(tier_id, db)
# Per-plan overage_percent overrides global config default
overage_percent: int = tier.get("overage_percent", settings.subscription_overage_percent)
overage_factor: float = 1.0 + overage_percent / 100.0
from app.models import UserProfile
profile = db.query(UserProfile).filter(UserProfile.user_id == owner_id).first()
allow_overage: bool = bool(profile.allow_overage) if profile else False
billing_cycle: str = (profile.subscription_billing_cycle if profile else None) or "monthly"
period_start: datetime | None = profile.subscription_period_start if profile else None
# 1. Lifetime file cap (free tier) — always enforced regardless of overage flag
lifetime_limit: int = tier["lifetime_file_limit"]
if lifetime_limit > 0:
enforcement_limit = int(lifetime_limit * overage_factor)
count = get_lifetime_file_count(db, owner_id)
if count >= enforcement_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. Monthly cap — bypassed when allow_overage is True (future billing)
if allow_overage:
return
monthly_limit: int = tier["monthly_upload_limit"]
if monthly_limit > 0:
if billing_cycle == "yearly" and period_start is not None:
now = datetime.now(timezone.utc)
months = _months_elapsed(period_start, now)
cumulative_budget = int(monthly_limit * months * overage_factor)
cumulative_used = get_year_file_count(db, owner_id, period_start)
if cumulative_used >= cumulative_budget:
raise QuotaExceeded(
f"Annual document quota for the {tier['name']} plan has been reached. "
"Unused monthly quota carries forward — your limit resets on your annual "
"renewal date, or you can upgrade your plan.",
limit_type="monthly",
limit_value=monthly_limit,
current_value=cumulative_used,
)
else:
count = get_month_file_count(db, owner_id)
enforcement_limit = int(monthly_limit * overage_factor)
if count >= enforcement_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 file counts for *owner_id*, including carry-over data for yearly plans."""
from app.models import UserProfile
profile = db.query(UserProfile).filter(UserProfile.user_id == owner_id).first()
result: dict[str, int] = {
"lifetime": get_lifetime_file_count(db, owner_id),
"today": get_today_file_count(db, owner_id),
"month": get_month_file_count(db, owner_id),
}
if profile and (profile.subscription_billing_cycle or "monthly") == "yearly" and profile.subscription_period_start:
result["year_to_date"] = get_year_file_count(db, owner_id, profile.subscription_period_start)
return result
+4
View File
@@ -14,10 +14,12 @@ from app.views.general import router as general_router
from app.views.google_drive import router as google_drive_router
from app.views.license_routes import router as license_router # Add the license router
from app.views.onedrive import router as onedrive_router
from app.views.plans import router as plans_router # Admin Plan Designer
from app.views.queue import router as queue_router
from app.views.search import router as search_router
from app.views.settings import router as settings_router
from app.views.status import router as status_router
from app.views.subscriptions import router as subscriptions_router # Pricing + subscription pages
from app.views.wizard import router as wizard_router
# Create a main router that includes all the view routers
@@ -35,3 +37,5 @@ router.include_router(settings_router)
router.include_router(filemanager_router)
router.include_router(search_router)
router.include_router(queue_router)
router.include_router(subscriptions_router) # Pricing + subscription pages
router.include_router(plans_router) # Admin Plan Designer
+63 -11
View File
@@ -2,11 +2,12 @@
General routes for the application homepage and basic pages.
"""
from datetime import date
from datetime import date, datetime, timezone
from pathlib import Path
from fastapi import Depends, HTTPException, Request
from fastapi.responses import FileResponse, RedirectResponse
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.utils.config_validator import get_provider_status, validate_storage_configs
@@ -54,25 +55,76 @@ async def serve_index(request: Request, db: Session = Depends(get_db)):
and provider in ["dropbox", "nextcloud", "sftp", "s3", "ftp", "webdav", "google_drive", "onedrive"]
)
# Query the actual file count from the database
processed_files = 0
from app.models import FileRecord
today = datetime.now(timezone.utc).date()
# Global file counts (or per-user in multi-user mode)
from app.config import settings
user = request.session.get("user") or {}
is_admin = user.get("is_admin", False)
try:
# Import the model here to avoid circular imports
from app.models import FileRecord
total_files: int = db.query(func.count(FileRecord.id)).scalar() or 0
processed_files = db.query(FileRecord.id).count()
files_today: int = (
db.query(func.count(FileRecord.id)).filter(func.date(FileRecord.created_at) == today).scalar() or 0
)
files_month: int = (
db.query(func.count(FileRecord.id))
.filter(func.strftime("%Y-%m", FileRecord.created_at) == today.strftime("%Y-%m"))
.scalar()
or 0
)
files_with_ocr: int = db.query(func.count(FileRecord.id)).filter(FileRecord.ocr_text.isnot(None)).scalar() or 0
unique_users: int = (
db.query(func.count(func.distinct(FileRecord.owner_id))).filter(FileRecord.owner_id.isnot(None)).scalar()
or 0
)
except Exception as e:
# Log error but continue (don't break the page if DB query fails)
logger.error(f"Error counting files: {str(e)}")
logger.error(f"Error computing dashboard stats: {e}")
total_files = files_today = files_month = files_with_ocr = unique_users = 0
# Per-user usage for the subscription widget (multi-user only)
user_usage = None
user_tier = None
if settings.multi_user_enabled:
owner_id: str = user.get("username") or user.get("email") or user.get("sub") or ""
if owner_id:
try:
from app.utils.subscription import get_tier, get_user_tier_id, get_user_usage
tier_id = get_user_tier_id(db, owner_id)
user_tier = get_tier(tier_id)
user_usage = get_user_usage(db, owner_id)
except Exception as e:
logger.error(f"Error fetching subscription info: {e}")
# Create stats object to pass to the template
stats = {
"processed_files": processed_files,
"processed_files": total_files,
"files_today": files_today,
"files_month": files_month,
"files_with_ocr": files_with_ocr,
"unique_users": unique_users,
"active_integrations": configured_providers,
"storage_targets": configured_storage_targets,
}
return templates.TemplateResponse("index.html", {"request": request, "stats": stats})
return templates.TemplateResponse(
"index.html",
{
"request": request,
"stats": stats,
"user_usage": user_usage,
"user_tier": user_tier,
"multi_user_enabled": settings.multi_user_enabled,
"is_admin": is_admin,
},
)
@router.get("/about", include_in_schema=False)
+18
View File
@@ -0,0 +1,18 @@
"""View route for the admin Plan Designer page."""
from fastapi import Request
from fastapi.responses import HTMLResponse
from fastapi.routing import APIRouter
from fastapi.templating import Jinja2Templates
from app.auth import require_login
router = APIRouter()
templates = Jinja2Templates(directory="frontend/templates")
@router.get("/admin/plans", response_class=HTMLResponse)
@require_login
async def plan_designer(request: Request) -> HTMLResponse:
"""Admin Plan Designer page."""
return templates.TemplateResponse("admin_plans.html", {"request": request})
+64
View File
@@ -0,0 +1,64 @@
"""View routes for subscription-related pages.
Routes:
GET /pricing — public marketing pricing page
GET /subscription — authenticated user's current plan & usage
"""
import logging
from fastapi import Depends, Request
from sqlalchemy.orm import Session
from app.utils.subscription import TIER_ORDER, get_all_tiers, get_tier, get_user_tier_id, get_user_usage
from app.views.base import APIRouter, get_db, require_login, templates
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/pricing", include_in_schema=False)
async def pricing_page(request: Request, db: Session = Depends(get_db)):
"""Public-facing pricing and plans page."""
tiers = get_all_tiers(db)
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, db)
all_tiers = get_all_tiers(db)
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,
},
)
+10
View File
@@ -195,6 +195,16 @@ Admins can assign ownership of documents to any user:
The `DEFAULT_OWNER_ID` setting can also be configured via the Settings page, which provides an
autocomplete field that searches existing users by substring.
### Subscriptions & Upload Quotas
DocuElevate supports configurable subscription plans with per-user upload quotas enforced at upload time.
Plans are managed via the **Plan Designer** at `/admin/plans`. The following global setting controls the
default overage buffer applied across all plans.
| **Variable** | **Description** | **Default** |
|----------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------|
| `SUBSCRIPTION_OVERAGE_PERCENT` | Soft-limit overage buffer in percent (0200). The announced monthly quota is multiplied by `(1 + percent/100)` for actual enforcement. E.g. `20` means a 150-doc/month plan enforces at 180 docs (150 × 1.20). Set `0` to enforce exactly at the announced limit. Per-plan `overage_percent` configured in the Plan Designer overrides this global default. | `20` |
### Security Headers
DocuElevate supports HTTP security headers to improve browser-side security. **These headers are disabled by default** since most deployments use a reverse proxy (Traefik, Nginx, etc.) that already adds them. Enable only if deploying directly without a reverse proxy. See [Deployment Guide - Security Headers](DeploymentGuide.md#security-headers) for detailed configuration examples.
+117
View File
@@ -0,0 +1,117 @@
# Subscription Tiers
DocuElevate uses database-backed subscription plans that are fully configurable by admins via the **Plan Designer** at `/admin/plans`. Four default tiers are seeded automatically on first startup.
## Default Plans
| Plan | Monthly | Yearly | Docs/Month | Lifetime Docs | OCR Pages/Mo | Max File | Mailboxes | Destinations |
|------|---------|--------|-----------|---------------|--------------|----------|-----------|--------------|
| **Free** | $0 | $0 | — | 50 total | 150 total | 5 MB | 0 | 1 |
| **Starter** | $2.99 | $28.99 | 50 | — | 300 | 25 MB | 1 | 2 |
| **Professional** | $5.99 | $57.99 | 150 | — | 750 | 100 MB | 3 | 5 |
| **Business** | $7.99 | $76.99 | 300 | — | 1,500 | Unlimited | Unlimited | 10 |
> Prices ex-VAT. German customers add 19% MwSt.
All paid plans include a **30-day free trial**.
## How Plans Are Stored
Plans are stored in the `subscription_plans` database table. On application startup, `seed_default_plans()` is called automatically — if the table is empty, the four built-in defaults are inserted. If plans already exist, the seed is a no-op.
Users are assigned a plan via `UserProfile.subscription_tier` (stores the `plan_id` string). The subscription utility functions (`get_tier`, `get_all_tiers`) query the database first and fall back to the hard-coded `TIER_DEFAULTS` dict if the database is unavailable or the plan doesn't exist.
## Overage Buffer
### Announced vs. Enforced Limit
DocuElevate uses a **soft-limit overage buffer** that is invisible to users:
- The **announced limit** is what appears on the pricing page (e.g., "150 docs/month").
- The **enforced limit** = announced × (1 + overage_percent / 100).
- With the default 20% buffer: a 150-doc plan enforces at **180 docs**.
- This prevents hard cutoffs at the exact announced limit, giving users a graceful landing.
### Per-Plan vs. Global Buffer
Each plan has its own `overage_percent` field (set in the Plan Designer). There is also a global fallback: `settings.subscription_overage_percent` (default: 20, range: 0200), which applies when a plan does not have an explicit value.
Set `subscription_overage_percent=0` in your `.env` to enforce exactly at the announced limit with no buffer.
## Yearly Billing & Carry-Over
When a user's `subscription_billing_cycle` is set to `yearly`:
- Unused quota from earlier months **rolls forward automatically**.
- Enforcement = `monthly_limit × months_elapsed × overage_factor` (cumulative budget from the subscription start date).
- Example: A 50-doc/month Starter plan in month 3 of its annual period has a cumulative budget of 150 docs (plus overage buffer). If the user only used 20 docs in months 12, they can use 130 docs in month 3.
- The `subscription_period_start` field on `UserProfile` tracks the start of the annual period.
## No Daily Cap
`daily_upload_limit` is kept for display and future reference only — it is **never enforced**. All enforcement is lifetime (free tier) or monthly/cumulative-yearly (paid tiers).
## allow_overage Flag
Setting `UserProfile.allow_overage = True` bypasses monthly quota checks entirely for that user. Usage is still tracked so future billing integrations can charge retroactively. This field is not yet exposed in the admin UI.
## Plan Designer
Navigate to `/admin/plans` (admin only) to:
1. **View** all plans (active and inactive) with key stats.
2. **Create** a new plan with a custom `plan_id` slug.
3. **Edit** any plan's pricing, limits, overage buffer, features, and display settings.
4. **Reorder** plans using the up/down arrows (order reflects pricing page display order).
5. **Delete** a plan (does not affect existing users assigned to it).
6. **Restore Defaults** — seeds the four built-in plans (no-op if plans already exist).
### Overage Designer
The Plan Designer includes an overage slider (0100%). The live preview shows:
> "Announce **X** docs, enforce at **Y** docs"
Overage billing and per-doc overage pricing are planned future features (currently disabled in the UI).
## API Endpoints
All plan endpoints are under `/api/plans/`.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `GET` | `/api/plans/` | Public | List active plans in sort order |
| `GET` | `/api/plans/admin` | Admin | List all plans including inactive |
| `POST` | `/api/plans/?plan_id=<id>` | Admin | Create a new plan |
| `GET` | `/api/plans/{plan_id}` | Public | Get a single active plan |
| `PUT` | `/api/plans/{plan_id}` | Admin | Update an existing plan |
| `DELETE` | `/api/plans/{plan_id}` | Admin | Delete a plan |
| `POST` | `/api/plans/seed` | Admin | Seed default plans (no-op if non-empty) |
| `POST` | `/api/plans/reorder` | Admin | Update sort order; body: `{"order": ["free", "starter", ...]}` |
### Example: List Active Plans
```bash
curl http://localhost:8000/api/plans/
```
### Example: Update a Plan's Monthly Limit
```bash
curl -X PUT http://localhost:8000/api/plans/starter \
-H "Content-Type: application/json" \
-d '{
"name": "Starter",
"monthly_upload_limit": 75,
"overage_percent": 15,
...
}'
```
## Configuration
| Variable | Default | Description |
|----------|---------|-------------|
| `SUBSCRIPTION_OVERAGE_PERCENT` | `20` | Global overage buffer (0200). Per-plan setting overrides this. |
See `docs/ConfigurationGuide.md` for all available settings.
+13 -3
View File
@@ -141,25 +141,35 @@ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', fun
if (authSection) {
authSection.textContent = ''; // Clear existing content
const container = document.createElement('div');
container.className = 'flex items-center';
container.className = 'flex items-center gap-2';
const img = document.createElement('img');
img.src = data.picture;
img.alt = 'Avatar';
img.className = 'w-8 h-8 rounded-full mr-2';
img.className = 'w-8 h-8 rounded-full';
const span = document.createElement('span');
span.textContent = displayName;
// Subscription link
const planLink = document.createElement('a');
planLink.href = '/subscription';
planLink.className = 'text-xs text-indigo-600 hover:text-indigo-800 font-medium hidden md:inline';
planLink.title = 'My subscription';
const planIcon = document.createElement('i');
planIcon.className = 'fas fa-layer-group';
planLink.appendChild(planIcon);
const logoutLink = document.createElement('a');
logoutLink.href = '/logout';
logoutLink.className = 'ml-3 text-red-600 hover:text-red-800';
logoutLink.className = 'text-red-600 hover:text-red-800';
const icon = document.createElement('i');
icon.className = 'fas fa-sign-out-alt';
logoutLink.appendChild(icon);
container.appendChild(img);
container.appendChild(span);
container.appendChild(planLink);
container.appendChild(logoutLink);
authSection.appendChild(container);
}
+607
View File
@@ -0,0 +1,607 @@
{% extends "base.html" %}
{% block title %}Plan Designer — DocuElevate Admin{% endblock %}
{% block content %}
<main id="main-content" class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"
x-data="planDesigner()"
x-init="init()">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-900">Plan Designer</h1>
<p class="text-sm text-gray-500 mt-1">Manage subscription plans shown on the public pricing page.</p>
</div>
<div class="flex items-center gap-3">
<button
@click="seedDefaults()"
:disabled="seeding"
class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-400 disabled:opacity-50"
title="Restore all four default plans (only if no plans exist yet)"
>
<i class="fas fa-undo mr-2 text-gray-400" aria-hidden="true"></i>
<span x-show="!seeding">Restore Defaults</span>
<span x-show="seeding"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Restoring…</span>
</button>
<button
@click="openCreateModal()"
class="inline-flex items-center px-4 py-2 rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-500"
>
<i class="fas fa-plus mr-2" aria-hidden="true"></i> Add Plan
</button>
</div>
</div>
<!-- Overage callout -->
<div class="mb-6 rounded-lg border border-indigo-200 bg-indigo-50 p-4 text-sm text-indigo-800" role="note">
<p class="font-semibold mb-1"><i class="fas fa-info-circle mr-1" aria-hidden="true"></i> About the Overage Buffer</p>
<p>
The overage buffer is <strong>invisible to users</strong>. We advertise X docs/month but only enforce
at <strong>X × (1 + buffer%)</strong> docs. For example, a 150-doc/month plan with a 20% buffer
enforces at 180 docs. This prevents hard cutoffs at the exact announced limit, giving users a
graceful soft landing.
</p>
</div>
<!-- Error / success messages -->
<div x-show="errorMsg" x-cloak class="mb-4 rounded-md bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700" role="alert">
<i class="fas fa-exclamation-circle mr-1" aria-hidden="true"></i>
<span x-text="errorMsg"></span>
</div>
<div x-show="successMsg" x-cloak class="mb-4 rounded-md bg-green-50 border border-green-200 px-4 py-3 text-sm text-green-700" role="status" aria-live="polite">
<i class="fas fa-check-circle mr-1" aria-hidden="true"></i>
<span x-text="successMsg"></span>
</div>
<!-- Loading -->
<div x-show="loading" class="text-center py-12 text-gray-400">
<i class="fas fa-spinner fa-spin text-2xl" aria-hidden="true"></i>
<p class="mt-2 text-sm">Loading plans…</p>
</div>
<!-- Plans table -->
<div x-show="!loading" class="bg-white shadow-sm rounded-lg overflow-hidden border border-gray-200">
<table class="min-w-full divide-y divide-gray-200" aria-label="Subscription plans">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Order</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Plan</th>
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Monthly</th>
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Yearly</th>
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Monthly Limit</th>
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Overage %</th>
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Active</th>
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-100">
<template x-if="plans.length === 0">
<tr>
<td colspan="8" class="px-4 py-8 text-center text-sm text-gray-400">
No plans yet. Click <strong>Restore Defaults</strong> to seed the four built-in plans.
</td>
</tr>
</template>
<template x-for="(plan, idx) in plans" :key="plan.plan_id">
<tr class="hover:bg-gray-50 transition">
<td class="px-4 py-3 text-sm text-gray-500">
<div class="flex items-center gap-1">
<button
@click="moveUp(idx)"
:disabled="idx === 0"
class="p-1 rounded hover:bg-gray-200 disabled:opacity-30"
:aria-label="'Move ' + plan.name + ' up'"
><i class="fas fa-chevron-up text-xs" aria-hidden="true"></i></button>
<button
@click="moveDown(idx)"
:disabled="idx === plans.length - 1"
class="p-1 rounded hover:bg-gray-200 disabled:opacity-30"
:aria-label="'Move ' + plan.name + ' down'"
><i class="fas fa-chevron-down text-xs" aria-hidden="true"></i></button>
<span x-text="idx + 1" class="w-5 text-center text-xs text-gray-400"></span>
</div>
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<span class="font-medium text-gray-900" x-text="plan.name"></span>
<template x-if="plan.badge_text">
<span class="text-xs px-2 py-0.5 rounded-full bg-indigo-100 text-indigo-700 font-medium" x-text="plan.badge_text"></span>
</template>
<template x-if="plan.is_highlighted">
<span class="text-xs px-2 py-0.5 rounded-full bg-yellow-100 text-yellow-700 font-medium"><i class="fas fa-star" aria-hidden="true"></i> Featured</span>
</template>
</div>
<div class="text-xs text-gray-400 mt-0.5" x-text="plan.plan_id"></div>
</td>
<td class="px-4 py-3 text-right text-sm text-gray-700">
<span x-text="plan.price_monthly === 0 ? 'Free' : '$' + plan.price_monthly.toFixed(2)"></span>
</td>
<td class="px-4 py-3 text-right text-sm text-gray-700">
<span x-text="plan.price_yearly === 0 ? '—' : '$' + plan.price_yearly.toFixed(2)"></span>
</td>
<td class="px-4 py-3 text-right text-sm text-gray-700">
<span x-text="plan.monthly_upload_limit === 0 ? '∞' : plan.monthly_upload_limit + ' docs/mo'"></span>
</td>
<td class="px-4 py-3 text-right text-sm">
<span class="font-medium text-indigo-700" x-text="plan.overage_percent + '%'"></span>
</td>
<td class="px-4 py-3 text-center">
<span
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
:class="plan.is_active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'"
x-text="plan.is_active ? 'Active' : 'Inactive'"
></span>
</td>
<td class="px-4 py-3 text-right">
<div class="flex items-center justify-end gap-2">
<button
@click="openEditModal(plan)"
class="text-sm text-indigo-600 hover:text-indigo-800 font-medium"
:aria-label="'Edit ' + plan.name"
>
<i class="fas fa-pencil-alt" aria-hidden="true"></i> Edit
</button>
<button
@click="deletePlan(plan.plan_id)"
class="text-sm text-red-500 hover:text-red-700 font-medium"
:aria-label="'Delete ' + plan.name"
>
<i class="fas fa-trash-alt" aria-hidden="true"></i> Delete
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Save order button -->
<div x-show="orderDirty && !loading" x-cloak class="mt-4 flex justify-end">
<button
@click="saveOrder()"
:disabled="saving"
class="inline-flex items-center px-4 py-2 rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-500 disabled:opacity-50"
>
<i class="fas fa-save mr-2" aria-hidden="true"></i>
<span x-show="!saving">Save Order</span>
<span x-show="saving"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Saving…</span>
</button>
</div>
<!-- ── Create / Edit Modal ─────────────────────────────────────────────── -->
<div
x-show="modalOpen"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="fixed inset-0 z-50 flex items-start justify-center bg-black bg-opacity-50 px-4 py-8 overflow-y-auto"
role="dialog"
aria-modal="true"
:aria-labelledby="'modal-title'"
@keydown.escape.window="modalOpen = false"
x-cloak
>
<div class="bg-white rounded-lg shadow-xl w-full max-w-2xl my-auto" @click.outside="modalOpen = false">
<!-- Modal header -->
<div class="flex items-center justify-between px-6 py-4 border-b">
<h2 id="modal-title" class="text-lg font-semibold text-gray-900" x-text="isCreate ? 'Add Plan' : 'Edit Plan: ' + form.name"></h2>
<button @click="modalOpen = false" class="text-gray-400 hover:text-gray-600" aria-label="Close modal">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</div>
<form @submit.prevent="savePlan()" class="divide-y divide-gray-100">
<!-- Basic Info -->
<div class="px-6 py-5 space-y-4">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">Basic Info</h3>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="f-name" class="block text-sm font-medium text-gray-700 mb-1">Name <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="f-name" type="text" x-model="form.name" required
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
</div>
<div>
<label for="f-plan-id" class="block text-sm font-medium text-gray-700 mb-1">Plan ID <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="f-plan-id" type="text" x-model="form.plan_id" :readonly="!isCreate"
:class="!isCreate ? 'bg-gray-50 text-gray-400 cursor-not-allowed' : ''"
required pattern="[a-z0-9_-]+" title="Lowercase letters, numbers, _ and - only"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
<p class="text-xs text-gray-400 mt-0.5">Lowercase slug, cannot be changed after creation.</p>
</div>
</div>
<div>
<label for="f-tagline" class="block text-sm font-medium text-gray-700 mb-1">Tagline</label>
<input id="f-tagline" type="text" x-model="form.tagline" maxlength="255"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
</div>
<div class="grid grid-cols-3 gap-4">
<div>
<label for="f-sort-order" class="block text-sm font-medium text-gray-700 mb-1">Sort Order</label>
<input id="f-sort-order" type="number" x-model.number="form.sort_order" min="0"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
</div>
<div class="flex items-center gap-3 pt-6">
<input id="f-active" type="checkbox" x-model="form.is_active"
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
<label for="f-active" class="text-sm font-medium text-gray-700">Active</label>
</div>
<div class="flex items-center gap-3 pt-6">
<input id="f-highlighted" type="checkbox" x-model="form.is_highlighted"
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
<label for="f-highlighted" class="text-sm font-medium text-gray-700">Featured / Highlighted</label>
</div>
</div>
</div>
<!-- Pricing -->
<div class="px-6 py-5 space-y-4">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">Pricing</h3>
<div class="grid grid-cols-3 gap-4">
<div>
<label for="f-price-monthly" class="block text-sm font-medium text-gray-700 mb-1">Monthly Price ($)</label>
<input id="f-price-monthly" type="number" x-model.number="form.price_monthly" min="0" step="0.01"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
</div>
<div>
<label for="f-price-yearly" class="block text-sm font-medium text-gray-700 mb-1">Yearly Price ($)</label>
<input id="f-price-yearly" type="number" x-model.number="form.price_yearly" min="0" step="0.01"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
<p class="text-xs mt-0.5"
:class="yearlySavingPct > 0 ? 'text-green-600 font-medium' : 'text-gray-400'"
x-text="yearlySavingPct > 0 ? 'Save ' + yearlySavingPct.toFixed(0) + '% vs monthly' : 'Enter yearly price to show savings'">
</p>
</div>
<div>
<label for="f-trial-days" class="block text-sm font-medium text-gray-700 mb-1">Trial Days</label>
<input id="f-trial-days" type="number" x-model.number="form.trial_days" min="0"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
</div>
</div>
</div>
<!-- Volume Limits -->
<div class="px-6 py-5 space-y-4">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">Volume Limits</h3>
<p class="text-xs text-gray-500">Enter <strong>0</strong> for unlimited.</p>
<div class="grid grid-cols-3 gap-4">
<div>
<label for="f-monthly-limit" class="block text-sm font-medium text-gray-700 mb-1">Docs / Month</label>
<input id="f-monthly-limit" type="number" x-model.number="form.monthly_upload_limit" min="0"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
</div>
<div>
<label for="f-lifetime-limit" class="block text-sm font-medium text-gray-700 mb-1">Lifetime Docs</label>
<input id="f-lifetime-limit" type="number" x-model.number="form.lifetime_file_limit" min="0"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
</div>
<div>
<label for="f-ocr-pages" class="block text-sm font-medium text-gray-700 mb-1">OCR Pages / Month</label>
<input id="f-ocr-pages" type="number" x-model.number="form.max_ocr_pages_monthly" min="0"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
</div>
<div>
<label for="f-dests" class="block text-sm font-medium text-gray-700 mb-1">Storage Destinations</label>
<input id="f-dests" type="number" x-model.number="form.max_storage_destinations" min="0"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
</div>
<div>
<label for="f-mailboxes" class="block text-sm font-medium text-gray-700 mb-1">Email Mailboxes</label>
<input id="f-mailboxes" type="number" x-model.number="form.max_mailboxes" min="0"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
</div>
<div>
<label for="f-filesize" class="block text-sm font-medium text-gray-700 mb-1">Max File Size (MB)</label>
<input id="f-filesize" type="number" x-model.number="form.max_file_size_mb" min="0"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
</div>
</div>
</div>
<!-- Overage Designer -->
<div class="px-6 py-5 space-y-4">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">Overage Designer</h3>
<div>
<label for="f-overage-pct" class="block text-sm font-medium text-gray-700 mb-2">
Buffer:
<span class="text-indigo-700 font-semibold" x-text="form.overage_percent + '%'"></span>
<template x-if="form.monthly_upload_limit > 0">
<span class="text-gray-500 font-normal ml-2">
→ announce <strong x-text="form.monthly_upload_limit"></strong> docs,
enforce at <strong class="text-indigo-700" x-text="Math.round(form.monthly_upload_limit * (1 + form.overage_percent / 100))"></strong> docs
</span>
</template>
</label>
<input
id="f-overage-pct"
type="range"
x-model.number="form.overage_percent"
min="0" max="100" step="5"
class="w-full h-2 rounded-lg appearance-none cursor-pointer accent-indigo-600"
aria-valuenow="form.overage_percent"
aria-valuemin="0"
aria-valuemax="100"
/>
<div class="flex justify-between text-xs text-gray-400 mt-1">
<span>0% (exact)</span>
<span>50%</span>
<span>100%</span>
</div>
</div>
<div class="grid grid-cols-2 gap-4 opacity-60">
<div class="relative">
<label class="block text-sm font-medium text-gray-700 mb-1">Allow Overage Billing</label>
<div class="flex items-center gap-2">
<input type="checkbox" disabled class="h-4 w-4 rounded border-gray-300 text-indigo-600" />
<span class="text-xs text-gray-400">Coming soon</span>
</div>
</div>
<div class="relative">
<label class="block text-sm font-medium text-gray-700 mb-1">Overage price / doc ($)</label>
<input type="number" disabled placeholder="Coming soon"
class="w-full px-3 py-2 border border-gray-200 rounded-md text-sm bg-gray-50 text-gray-400 cursor-not-allowed" />
</div>
<div class="relative">
<label class="block text-sm font-medium text-gray-700 mb-1">Overage price / OCR page ($)</label>
<input type="number" disabled placeholder="Coming soon"
class="w-full px-3 py-2 border border-gray-200 rounded-md text-sm bg-gray-50 text-gray-400 cursor-not-allowed" />
</div>
</div>
</div>
<!-- Features -->
<div class="px-6 py-5 space-y-3">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">Features List</h3>
<p class="text-xs text-gray-500">These bullet points appear on the pricing page card for this plan.</p>
<template x-for="(feat, i) in form.features" :key="i">
<div class="flex items-center gap-2">
<input
type="text"
x-model="form.features[i]"
:aria-label="'Feature ' + (i+1)"
class="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
/>
<button type="button" @click="form.features.splice(i, 1)"
:aria-label="'Remove feature ' + (i+1)"
class="text-red-400 hover:text-red-600 px-2 py-2">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</div>
</template>
<button type="button" @click="form.features.push('')"
class="inline-flex items-center text-sm text-indigo-600 hover:text-indigo-800 font-medium">
<i class="fas fa-plus mr-1" aria-hidden="true"></i> Add Feature
</button>
</div>
<!-- Display -->
<div class="px-6 py-5 space-y-4">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider">Display</h3>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="f-cta" class="block text-sm font-medium text-gray-700 mb-1">CTA Button Text</label>
<input id="f-cta" type="text" x-model="form.cta_text" maxlength="100"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
</div>
<div>
<label for="f-badge" class="block text-sm font-medium text-gray-700 mb-1">Badge Text</label>
<input id="f-badge" type="text" x-model="form.badge_text" maxlength="50"
placeholder="e.g. Most Popular"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
</div>
</div>
<div class="flex items-center gap-3">
<input id="f-api" type="checkbox" x-model="form.api_access"
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
<label for="f-api" class="text-sm font-medium text-gray-700">API Access</label>
</div>
</div>
<!-- Footer -->
<div class="px-6 py-4 border-t flex justify-end gap-3">
<button
type="button"
@click="modalOpen = false"
class="px-4 py-2 text-sm font-medium border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-gray-400"
>
Cancel
</button>
<button
type="submit"
:disabled="saving"
class="px-4 py-2 text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span x-show="!saving" x-text="isCreate ? 'Create Plan' : 'Save Changes'"></span>
<span x-show="saving"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Saving…</span>
</button>
</div>
</form>
</div>
</div>
</main>
<script>
function planDesigner() {
return {
plans: [],
loading: true,
saving: false,
seeding: false,
modalOpen: false,
isCreate: true,
orderDirty: false,
errorMsg: '',
successMsg: '',
form: {
plan_id: '',
name: '',
tagline: '',
price_monthly: 0,
price_yearly: 0,
trial_days: 0,
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,
max_mailboxes: 0,
overage_percent: 20,
allow_overage_billing: false,
overage_price_per_doc: null,
overage_price_per_ocr_page: null,
is_active: true,
is_highlighted: false,
badge_text: null,
cta_text: 'Get started',
sort_order: 0,
features: [],
api_access: false,
},
get yearlySavingPct() {
if (!this.form.price_monthly || !this.form.price_yearly) return 0;
const annualMonthly = this.form.price_monthly * 12;
return ((annualMonthly - this.form.price_yearly) / annualMonthly) * 100;
},
async init() {
await this.loadPlans();
},
async loadPlans() {
this.loading = true;
this.errorMsg = '';
try {
const resp = await fetch('/api/plans/admin');
if (!resp.ok) throw new Error('Failed to load plans');
const data = await resp.json();
this.plans = data.plans || [];
} catch (e) {
this.errorMsg = e.message;
} finally {
this.loading = false;
}
},
openCreateModal() {
this.isCreate = true;
this.form = {
plan_id: '', name: '', tagline: '', price_monthly: 0, price_yearly: 0, trial_days: 0,
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, max_mailboxes: 0,
overage_percent: 20, allow_overage_billing: false, overage_price_per_doc: null,
overage_price_per_ocr_page: null, is_active: true, is_highlighted: false,
badge_text: null, cta_text: 'Get started', sort_order: this.plans.length, features: [], api_access: false,
};
this.modalOpen = true;
},
openEditModal(plan) {
this.isCreate = false;
this.form = Object.assign({}, plan, { features: [...(plan.features || [])] });
this.modalOpen = true;
},
async savePlan() {
this.saving = true;
this.errorMsg = '';
this.successMsg = '';
try {
const url = this.isCreate ? `/api/plans/?plan_id=${encodeURIComponent(this.form.plan_id)}` : `/api/plans/${encodeURIComponent(this.form.plan_id)}`;
const method = this.isCreate ? 'POST' : 'PUT';
const resp = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.form),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
throw new Error(err.detail || 'Save failed');
}
this.successMsg = this.isCreate ? 'Plan created!' : 'Plan updated!';
this.modalOpen = false;
await this.loadPlans();
} catch (e) {
this.errorMsg = e.message;
} finally {
this.saving = false;
}
},
async deletePlan(planId) {
if (!confirm(`Delete plan "${planId}"? This cannot be undone.`)) return;
this.errorMsg = '';
try {
const resp = await fetch(`/api/plans/${encodeURIComponent(planId)}`, { method: 'DELETE' });
if (!resp.ok && resp.status !== 204) {
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
throw new Error(err.detail || 'Delete failed');
}
this.successMsg = `Plan "${planId}" deleted.`;
await this.loadPlans();
} catch (e) {
this.errorMsg = e.message;
}
},
async seedDefaults() {
if (!confirm('Seed the four default plans? This is a no-op if plans already exist.')) return;
this.seeding = true;
this.errorMsg = '';
try {
const resp = await fetch('/api/plans/seed', { method: 'POST' });
if (!resp.ok) throw new Error('Seed failed');
const data = await resp.json();
this.successMsg = data.message;
await this.loadPlans();
} catch (e) {
this.errorMsg = e.message;
} finally {
this.seeding = false;
}
},
moveUp(idx) {
if (idx === 0) return;
[this.plans[idx - 1], this.plans[idx]] = [this.plans[idx], this.plans[idx - 1]];
this.orderDirty = true;
},
moveDown(idx) {
if (idx >= this.plans.length - 1) return;
[this.plans[idx], this.plans[idx + 1]] = [this.plans[idx + 1], this.plans[idx]];
this.orderDirty = true;
},
async saveOrder() {
this.saving = true;
this.errorMsg = '';
try {
const order = this.plans.map(p => p.plan_id);
const resp = await fetch('/api/plans/reorder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order }),
});
if (!resp.ok) throw new Error('Reorder failed');
this.successMsg = 'Order saved!';
this.orderDirty = false;
} catch (e) {
this.errorMsg = e.message;
} finally {
this.saving = false;
}
},
};
}
</script>
{% endblock %}
+85 -3
View File
@@ -68,6 +68,7 @@
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Display Name</th>
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Documents</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Last Upload</th>
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Plan</th>
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Upload Limit</th>
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
@@ -76,14 +77,14 @@
<tbody class="bg-white divide-y divide-gray-200">
<template x-if="loading">
<tr>
<td colspan="7" class="px-4 py-8 text-center text-gray-400">
<td colspan="8" class="px-4 py-8 text-center text-gray-400">
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i> Loading users…
</td>
</tr>
</template>
<template x-if="!loading && users.length === 0">
<tr>
<td colspan="7" class="px-4 py-8 text-center text-gray-400">
<td colspan="8" class="px-4 py-8 text-center text-gray-400">
<i class="fas fa-users-slash text-3xl block mb-2" aria-hidden="true"></i>
No users found.
<span x-show="search"> Try a different search term.</span>
@@ -117,6 +118,29 @@
<!-- Last upload -->
<td class="px-4 py-3 text-sm text-gray-500 whitespace-nowrap"
x-text="user.last_upload ? formatDate(user.last_upload) : '—'"></td>
<!-- Subscription plan -->
<td class="px-4 py-3 text-sm text-center">
<span
:class="{
'bg-gray-100 text-gray-600': !user.subscription_tier || user.subscription_tier === 'free',
'bg-blue-100 text-blue-700': user.subscription_tier === 'starter',
'bg-indigo-100 text-indigo-700': user.subscription_tier === 'professional',
'bg-purple-100 text-purple-700': user.subscription_tier === 'business',
}"
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold capitalize"
>
<i
:class="{
'fas fa-seedling': !user.subscription_tier || user.subscription_tier === 'free',
'fas fa-rocket': user.subscription_tier === 'starter',
'fas fa-star': user.subscription_tier === 'professional',
'fas fa-building': user.subscription_tier === 'business',
}"
aria-hidden="true"
></i>
<span x-text="user.subscription_tier || 'free'"></span>
</span>
</td>
<!-- Upload limit -->
<td class="px-4 py-3 text-sm text-center text-gray-700">
<span x-show="user.daily_upload_limit !== null && user.daily_upload_limit !== undefined"
@@ -300,6 +324,57 @@
</label>
</div>
<!-- Subscription tier -->
<div>
<label for="modal-tier" class="block text-sm font-medium text-gray-700 mb-1">
Subscription Plan
</label>
<select
id="modal-tier"
x-model="form.subscription_tier"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 bg-white"
>
<option value="free">Free — 25 lifetime files</option>
<option value="starter">Starter — $2.99/mo (50/mo, 1 mailbox)</option>
<option value="professional">Professional — $5.99/mo (150/mo, 3 mailboxes)</option>
<option value="business">Business — $7.99/mo (300/mo, unlimited mailboxes)</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>
<!-- Billing cycle -->
<div>
<label for="modal-billing-cycle" class="block text-sm font-medium text-gray-700 mb-1">
Billing Cycle
</label>
<select
id="modal-billing-cycle"
x-model="form.subscription_billing_cycle"
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="monthly">Monthly</option>
<option value="yearly">Yearly</option>
</select>
</div>
<!-- Subscription period start (only shown for yearly) -->
<div x-show="form.subscription_billing_cycle === 'yearly'" x-cloak>
<label for="modal-period-start" class="block text-sm font-medium text-gray-700 mb-1">
Subscription Period Start
</label>
<input
type="date"
id="modal-period-start"
x-model="form.subscription_period_start"
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"
/>
<p class="text-xs text-gray-400 mt-1">
Annual carry-over calculates from this date. Leave blank for monthly enforcement.
</p>
</div>
</div>
<!-- Footer -->
@@ -394,6 +469,9 @@ function adminUsersApp() {
daily_upload_limit: null,
notes: '',
is_blocked: false,
subscription_tier: 'free',
subscription_billing_cycle: 'monthly',
subscription_period_start: null,
},
// Delete modal
@@ -446,7 +524,7 @@ function adminUsersApp() {
openCreateModal() {
this.isCreate = true;
this.modalTitle = 'Add User Profile';
this.form = { user_id: '', display_name: '', daily_upload_limit: null, notes: '', is_blocked: false };
this.form = { user_id: '', display_name: '', daily_upload_limit: null, notes: '', is_blocked: false, subscription_tier: 'free', subscription_billing_cycle: 'monthly', subscription_period_start: null };
this.modalOpen = true;
},
@@ -460,6 +538,9 @@ function adminUsersApp() {
? user.daily_upload_limit : null,
notes: user.notes || '',
is_blocked: !!user.is_blocked,
subscription_tier: user.subscription_tier || 'free',
subscription_billing_cycle: user.subscription_billing_cycle || 'monthly',
subscription_period_start: user.subscription_period_start ? user.subscription_period_start.substring(0, 10) : null,
};
this.modalOpen = true;
},
@@ -473,6 +554,7 @@ function adminUsersApp() {
? Number(this.form.daily_upload_limit) : null,
notes: this.form.notes || null,
is_blocked: !!this.form.is_blocked,
subscription_tier: this.form.subscription_tier || 'free',
};
const uid = encodeURIComponent(this.form.user_id);
const resp = await fetch(`/api/admin/users/${uid}`, {
+8
View File
@@ -63,6 +63,7 @@
<a href="/upload" class="text-gray-700 hover:text-gray-900" {% if request and request.url.path == '/upload' %}aria-current="page"{% endif %}>Upload</a>
<a href="/files" class="text-gray-700 hover:text-gray-900" {% if request and request.url.path == '/files' %}aria-current="page"{% endif %}>Files</a>
<a href="/search" class="text-gray-700 hover:text-gray-900" {% if request and request.url.path == '/search' %}aria-current="page"{% endif %}>Search</a>
<a href="/pricing" class="text-gray-700 hover:text-gray-900" {% if request and request.url.path == '/pricing' %}aria-current="page"{% endif %}>Pricing</a>
<!-- Admin dropdown (shown only for admin users via JS) -->
<div id="adminMenuContainer" class="relative hidden">
@@ -98,6 +99,9 @@
<a href="/admin/users" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-users w-4 mr-2 text-blue-500" aria-hidden="true"></i> Users
</a>
<a href="/admin/plans" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-layer-group w-4 mr-2 text-indigo-500" aria-hidden="true"></i> Plan Designer
</a>
<a href="/admin/credentials" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-key w-4 mr-2 text-yellow-500" aria-hidden="true"></i> Credentials
</a>
@@ -171,6 +175,7 @@
<a href="/upload" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">Upload</a>
<a href="/files" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">Files</a>
<a href="/search" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">Search</a>
<a href="/pricing" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">Pricing</a>
<!-- Admin section in mobile menu (shown only for admin users via JS) -->
<div id="mobileAdminSection" class="hidden">
@@ -184,6 +189,9 @@
<a href="/admin/users" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">
<i class="fas fa-users mr-2 text-blue-400" aria-hidden="true"></i> Users
</a>
<a href="/admin/plans" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">
<i class="fas fa-layer-group mr-2 text-indigo-400" aria-hidden="true"></i> Plan Designer
</a>
<a href="/admin/credentials" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">
<i class="fas fa-key mr-2 text-yellow-400" aria-hidden="true"></i> Credentials
</a>
+298
View File
@@ -4,6 +4,302 @@
{% block content %}
<div class="container mx-auto px-4 py-8">
{% if multi_user_enabled %}
{# ══════════════════════════════════════════════════════════════════════════ #}
{# MULTI-USER / SAAS DASHBOARD #}
{# ══════════════════════════════════════════════════════════════════════════ #}
<!-- Hero -->
<div class="bg-gradient-to-r from-blue-600 to-indigo-700 rounded-xl shadow-lg text-white p-8 mb-8">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 class="text-3xl font-bold mb-1">DocuElevate</h1>
<p class="text-blue-100 text-sm">Intelligent document processing &amp; management — built for teams</p>
</div>
<div class="flex flex-wrap gap-3">
<a href="/upload"
class="bg-white text-blue-700 hover:bg-blue-50 font-semibold py-2 px-5 rounded-lg transition duration-200 flex items-center shadow-sm">
<i class="fas fa-upload mr-2" aria-hidden="true"></i> Upload
</a>
<a href="/files"
class="bg-transparent border border-white text-white hover:bg-white hover:text-blue-700 font-semibold py-2 px-5 rounded-lg transition duration-200 flex items-center">
<i class="fas fa-list mr-2" aria-hidden="true"></i> Browse Files
</a>
</div>
</div>
</div>
<!-- Platform stats row (admin only) -->
{% if is_admin %}
<h2 class="text-sm font-semibold text-gray-400 uppercase tracking-widest mb-3">Platform overview</h2>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-8">
<div class="bg-white rounded-xl shadow p-5 flex items-center gap-3">
<div class="h-10 w-10 rounded-full bg-blue-100 flex items-center justify-center flex-shrink-0">
<i class="fas fa-file-alt text-blue-600" aria-hidden="true"></i>
</div>
<div>
<p class="text-2xl font-extrabold text-blue-600">{{ stats.processed_files | default(0) }}</p>
<p class="text-gray-500 text-xs">Total files</p>
</div>
</div>
<div class="bg-white rounded-xl shadow p-5 flex items-center gap-3">
<div class="h-10 w-10 rounded-full bg-green-100 flex items-center justify-center flex-shrink-0">
<i class="fas fa-calendar-day text-green-600" aria-hidden="true"></i>
</div>
<div>
<p class="text-2xl font-extrabold text-green-600">{{ stats.files_today | default(0) }}</p>
<p class="text-gray-500 text-xs">Files today</p>
</div>
</div>
<div class="bg-white rounded-xl shadow p-5 flex items-center gap-3">
<div class="h-10 w-10 rounded-full bg-indigo-100 flex items-center justify-center flex-shrink-0">
<i class="fas fa-calendar-alt text-indigo-600" aria-hidden="true"></i>
</div>
<div>
<p class="text-2xl font-extrabold text-indigo-600">{{ stats.files_month | default(0) }}</p>
<p class="text-gray-500 text-xs">Files this month</p>
</div>
</div>
<div class="bg-white rounded-xl shadow p-5 flex items-center gap-3">
<div class="h-10 w-10 rounded-full bg-purple-100 flex items-center justify-center flex-shrink-0">
<i class="fas fa-users text-purple-600" aria-hidden="true"></i>
</div>
<div>
<p class="text-2xl font-extrabold text-purple-600">{{ stats.unique_users | default(0) }}</p>
<p class="text-gray-500 text-xs">Active users</p>
</div>
</div>
</div>
{% endif %}
<!-- My usage / subscription widget -->
{% if user_tier and user_usage %}
<h2 class="text-sm font-semibold text-gray-400 uppercase tracking-widest mb-3">My usage</h2>
<div class="bg-white rounded-xl shadow p-6 mb-8">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
<div class="flex items-center gap-3">
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-bold
{% if user_tier.id == 'free' %}bg-gray-100 text-gray-600
{% elif user_tier.id == 'starter' %}bg-blue-100 text-blue-700
{% elif user_tier.id == 'professional' %}bg-indigo-100 text-indigo-700
{% else %}bg-purple-100 text-purple-700{% endif %}">
<i class="fas
{% if user_tier.id == 'free' %}fa-seedling
{% elif user_tier.id == 'starter' %}fa-rocket
{% elif user_tier.id == 'professional' %}fa-star
{% else %}fa-building{% endif %}
mr-1" aria-hidden="true"></i>
{{ user_tier.name }} Plan
</span>
{% if user_tier.id != 'business' %}
<a href="/pricing" class="text-xs text-indigo-600 hover:text-indigo-800 font-medium">
Upgrade <i class="fas fa-arrow-up-right-from-square ml-0.5 text-xs" aria-hidden="true"></i>
</a>
{% endif %}
</div>
<a href="/subscription" class="text-xs text-gray-500 hover:text-gray-700">
View full details →
</a>
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<!-- Lifetime -->
<div>
<div class="flex justify-between text-xs mb-1">
<span class="text-gray-500">Lifetime files</span>
<span class="font-semibold text-gray-700">
{{ user_usage.lifetime }}{% if user_tier.lifetime_file_limit > 0 %} / {{ user_tier.lifetime_file_limit }}{% endif %}
</span>
</div>
{% if user_tier.lifetime_file_limit > 0 %}
{% set pct = ([((user_usage.lifetime / user_tier.lifetime_file_limit) * 100) | int, 100] | min) %}
<div class="w-full bg-gray-100 rounded-full h-2" role="progressbar" aria-valuemin="0"
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-valuemin="0"
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-valuemin="0"
aria-valuenow="{{ user_usage.month }}" aria-valuemax="{{ user_tier.monthly_upload_limit }}">
<div class="h-2 rounded-full {% if pct >= 90 %}bg-red-500{% elif pct >= 70 %}bg-yellow-500{% else %}bg-indigo-500{% endif %}"
style="width: {{ pct }}%"></div>
</div>
{% else %}
<div class="text-xs text-green-600">Unlimited</div>
{% endif %}
</div>
</div>
</div>
{% endif %}
<!-- Widget grid (multi-user) -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
<!-- Quick Actions -->
<div class="bg-white rounded-xl shadow p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
<i class="fas fa-bolt text-yellow-500" aria-hidden="true"></i> Quick Actions
</h2>
<ul class="space-y-2">
<li>
<a href="/upload" class="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 transition group">
<span class="h-8 w-8 rounded-full bg-blue-100 flex items-center justify-center flex-shrink-0 group-hover:bg-blue-200">
<i class="fas fa-upload text-blue-600 text-sm" aria-hidden="true"></i>
</span>
<div>
<p class="text-sm font-medium text-gray-800">Upload Document</p>
<p class="text-xs text-gray-400">Process a new file</p>
</div>
</a>
</li>
<li>
<a href="/files" class="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 transition group">
<span class="h-8 w-8 rounded-full bg-indigo-100 flex items-center justify-center flex-shrink-0 group-hover:bg-indigo-200">
<i class="fas fa-list text-indigo-600 text-sm" aria-hidden="true"></i>
</span>
<div>
<p class="text-sm font-medium text-gray-800">My Documents</p>
<p class="text-xs text-gray-400">Browse your processed files</p>
</div>
</a>
</li>
<li>
<a href="/subscription" class="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 transition group">
<span class="h-8 w-8 rounded-full bg-purple-100 flex items-center justify-center flex-shrink-0 group-hover:bg-purple-200">
<i class="fas fa-layer-group text-purple-600 text-sm" aria-hidden="true"></i>
</span>
<div>
<p class="text-sm font-medium text-gray-800">My Subscription</p>
<p class="text-xs text-gray-400">View plan &amp; usage details</p>
</div>
</a>
</li>
<li>
<a href="/search" class="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 transition group">
<span class="h-8 w-8 rounded-full bg-green-100 flex items-center justify-center flex-shrink-0 group-hover:bg-green-200">
<i class="fas fa-search text-green-600 text-sm" aria-hidden="true"></i>
</span>
<div>
<p class="text-sm font-medium text-gray-800">Search</p>
<p class="text-xs text-gray-400">Full-text search across documents</p>
</div>
</a>
</li>
</ul>
</div>
<!-- Processing stats -->
<div class="bg-white rounded-xl shadow p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
<i class="fas fa-chart-bar text-blue-500" aria-hidden="true"></i> Processing Stats
</h2>
<ul class="space-y-3 text-sm">
<li class="flex items-center justify-between py-2 border-b border-gray-100">
<span class="text-gray-600 flex items-center gap-2">
<i class="fas fa-file-alt text-blue-400 w-4" aria-hidden="true"></i> Total processed
</span>
<span class="font-bold text-gray-900">{{ stats.processed_files | default(0) }}</span>
</li>
<li class="flex items-center justify-between py-2 border-b border-gray-100">
<span class="text-gray-600 flex items-center gap-2">
<i class="fas fa-eye text-indigo-400 w-4" aria-hidden="true"></i> Files with OCR
</span>
<span class="font-bold text-gray-900">{{ stats.files_with_ocr | default(0) }}</span>
</li>
<li class="flex items-center justify-between py-2 border-b border-gray-100">
<span class="text-gray-600 flex items-center gap-2">
<i class="fas fa-calendar-day text-green-400 w-4" aria-hidden="true"></i> Today
</span>
<span class="font-bold text-gray-900">{{ stats.files_today | default(0) }}</span>
</li>
<li class="flex items-center justify-between py-2">
<span class="text-gray-600 flex items-center gap-2">
<i class="fas fa-calendar-alt text-orange-400 w-4" aria-hidden="true"></i> This month
</span>
<span class="font-bold text-gray-900">{{ stats.files_month | default(0) }}</span>
</li>
</ul>
</div>
<!-- Upgrade CTA or Integrations -->
{% if user_tier and user_tier.id != 'business' %}
<div class="bg-gradient-to-br from-indigo-600 to-purple-700 rounded-xl shadow p-6 text-white flex flex-col justify-between">
<div>
<h2 class="text-lg font-semibold mb-2 flex items-center gap-2">
<i class="fas fa-arrow-up-right-dots" aria-hidden="true"></i> Upgrade your plan
</h2>
<p class="text-indigo-100 text-sm mb-4">
Unlock more documents, more destinations and priority support.
</p>
<ul class="space-y-1.5 text-sm text-indigo-100">
<li class="flex items-center gap-2"><i class="fas fa-check-circle text-indigo-300" aria-hidden="true"></i> Higher daily &amp; monthly limits</li>
<li class="flex items-center gap-2"><i class="fas fa-check-circle text-indigo-300" aria-hidden="true"></i> More storage destinations</li>
<li class="flex items-center gap-2"><i class="fas fa-check-circle text-indigo-300" aria-hidden="true"></i> More OCR pages</li>
</ul>
</div>
<a href="/pricing"
class="mt-6 inline-flex items-center justify-center bg-white text-indigo-700 font-semibold px-5 py-2.5 rounded-lg hover:bg-indigo-50 transition shadow">
View plans &amp; pricing <i class="fas fa-arrow-right ml-2 text-xs" aria-hidden="true"></i>
</a>
</div>
{% else %}
<div class="bg-white rounded-xl shadow p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
<i class="fas fa-plug text-green-500" aria-hidden="true"></i> Integrations
</h2>
<div class="space-y-3 text-sm text-gray-600">
<div class="flex items-center justify-between">
<span>Active integrations</span>
<span class="font-bold text-green-600">{{ stats.active_integrations | default(0) }}</span>
</div>
<div class="flex items-center justify-between">
<span>Storage targets</span>
<span class="font-bold text-indigo-600">{{ stats.storage_targets | default(0) }}</span>
</div>
</div>
<a href="/status" class="mt-4 inline-flex items-center text-xs text-indigo-600 hover:text-indigo-800">
View system status <i class="fas fa-arrow-right ml-1" aria-hidden="true"></i>
</a>
</div>
{% endif %}
</div>
{% else %}
{# ══════════════════════════════════════════════════════════════════════════ #}
{# SINGLE-USER DASHBOARD (original layout, preserved as-is) #}
{# ══════════════════════════════════════════════════════════════════════════ #}
<!-- Hero / Quick Actions -->
<div class="bg-gradient-to-r from-blue-600 to-indigo-700 rounded-xl shadow-lg text-white p-8 mb-8">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
@@ -154,6 +450,8 @@
</div>
</div>
{% endif %}{# end multi_user_enabled #}
</div>
{% endblock %}
+380
View File
@@ -0,0 +1,380 @@
{% 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 ~20%</span>
</button>
</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">
~${{ "%.2f"|format(tier.price_yearly / 12) }}/month billed annually
</div>
{% endif %}
</div>
</template>
</div>
<!-- Free trial badge for paid tiers -->
{% if tier.trial_days > 0 %}
<div class="mt-3 inline-flex items-center gap-1 text-xs font-semibold text-emerald-700 bg-emerald-50 border border-emerald-200 rounded-full px-3 py-1">
<i class="fas fa-gift" aria-hidden="true"></i>
{{ tier.trial_days }}-day free trial — no credit card required
</div>
{% endif %}
</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.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 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">{{ "{:,}".format(tier.max_ocr_pages_monthly) }}</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">Mailboxes (ingestion sources)</td>
{% for tier in tiers %}
<td class="px-4 py-3 text-center text-sm">
{% if tier.max_mailboxes == 0 and tier.id == 'free' %}
<i class="fas fa-times text-gray-300" aria-label="Not included"></i>
{% elif tier.max_mailboxes == 0 %}
<span class="font-semibold text-green-600">Unlimited</span>
{% else %}
<span class="font-medium text-gray-800">{{ tier.max_mailboxes }}</span>
{% endif %}
</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 50-file lifetime quota is reached you will see a friendly upgrade prompt on the upload page and any further upload attempts will return a payment-required error until you upgrade to a paid plan."),
("Can I change my plan at any time?",
"Yes. Upgrades take effect immediately. Downgrades take effect at the start of the next billing cycle. Unused quota does not roll over between billing periods."),
("Is there an annual discount?",
"Yes — paying annually saves approximately 20 % compared to monthly billing (≈ 2½ months free). The exact annual price and per-month equivalent are shown when you toggle to Annual above."),
("Is there a free trial for paid plans?",
"Yes! All three paid plans include a 30-day free trial — no credit card required. You can upgrade from the Free tier or start a trial directly from any paid plan card above."),
("What is a 'storage destination'?",
"A storage destination is any cloud or self-hosted storage you configure as an output — Dropbox, Google Drive, OneDrive, Nextcloud, S3, SFTP, FTP, WebDAV, or Paperless-ngx each count as one destination."),
("What is a 'mailbox'?",
"A mailbox is an email address DocuElevate monitors for incoming documents. Any attachment arriving at a configured mailbox is automatically processed through the pipeline. The Free tier does not include email ingestion."),
("Are prices inclusive of VAT?",
"Listed prices are exclusive of VAT. Customers in Germany are charged 19 % Mehrwertsteuer (MwSt) at checkout. EU business customers outside Germany apply the reverse-charge mechanism. Non-EU customers are not subject to German VAT."),
] %}
{% for q, a in faqs %}
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<button
@click="open = (open === {{ loop.index0 }}) ? null : {{ loop.index0 }}"
class="w-full flex justify-between items-center px-6 py-4 text-left text-sm font-semibold text-gray-800 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500"
:aria-expanded="open === {{ loop.index0 }}"
>
<span>{{ q }}</span>
<i class="fas text-indigo-400 ml-4 flex-shrink-0 transition-transform duration-200"
:class="open === {{ loop.index0 }} ? 'fa-chevron-up' : 'fa-chevron-down'"
aria-hidden="true"></i>
</button>
<div x-show="open === {{ loop.index0 }}" x-transition class="px-6 pb-4 text-sm text-gray-600">
{{ a }}
</div>
</div>
{% endfor %}
</div>
</div>
<!-- ── CTA strip ─────────────────────────────────────────────────────── -->
<div class="mt-20 rounded-2xl bg-gradient-to-r from-blue-600 to-indigo-700 text-white p-10 text-center shadow-xl">
<h2 class="text-2xl font-bold mb-2">Ready to get started?</h2>
<p class="text-indigo-100 mb-6">Start with the free tier — no credit card required.</p>
<a href="/login"
class="inline-block bg-white text-indigo-700 font-bold px-8 py-3 rounded-lg hover:bg-indigo-50 transition shadow">
Create your free account
</a>
</div>
</div>
</div>
{% endblock %}
+218
View File
@@ -0,0 +1,218 @@
{% extends "base.html" %}
{% block title %}My Subscription DocuElevate{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8 max-w-4xl">
<!-- Header -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 flex items-center gap-3">
<i class="fas fa-layer-group text-indigo-500" aria-hidden="true"></i>
My Subscription
</h1>
<p class="text-gray-500 text-sm mt-1">Your current plan, usage and available upgrades.</p>
</div>
{% if not multi_user_enabled %}
<!-- Single-user notice -->
<div class="bg-blue-50 border border-blue-200 rounded-xl p-6 flex items-start gap-4">
<i class="fas fa-info-circle text-blue-500 text-2xl flex-shrink-0 mt-0.5" aria-hidden="true"></i>
<div>
<p class="font-semibold text-blue-800">Multi-user mode is not enabled.</p>
<p class="text-blue-700 text-sm mt-1">
Subscription tiers apply when multi-user mode is active. Your instance currently runs in
single-user mode with no processing limits.
An admin can enable multi-user mode via <a href="/settings" class="underline hover:text-blue-900">Settings</a>.
</p>
</div>
</div>
{% else %}
<!-- Current plan card -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<!-- Plan badge -->
<div class="bg-white rounded-xl shadow p-6 flex flex-col items-center text-center
{% if tier_id == 'professional' %}ring-2 ring-indigo-500{% endif %}">
<div class="h-14 w-14 rounded-full
{% if tier_id == 'free' %}bg-gray-100{% elif tier_id == 'starter' %}bg-blue-100{% elif tier_id == 'professional' %}bg-indigo-100{% else %}bg-purple-100{% endif %}
flex items-center justify-center mb-3">
<i class="fas
{% if tier_id == 'free' %}fa-seedling text-gray-500
{% elif tier_id == 'starter' %}fa-rocket text-blue-600
{% elif tier_id == 'professional' %}fa-star text-indigo-600
{% else %}fa-building text-purple-600{% endif %}
text-2xl" aria-hidden="true"></i>
</div>
<p class="text-xs font-semibold text-gray-400 uppercase tracking-widest">Current Plan</p>
<p class="text-2xl font-extrabold text-gray-900 mt-1">{{ tier.name }}</p>
<p class="text-gray-500 text-sm mt-1">{{ tier.tagline }}</p>
{% if tier.price_monthly > 0 %}
<p class="mt-3 text-lg font-bold text-indigo-600">${{ tier.price_monthly }}<span class="text-sm font-normal text-gray-400">/month</span></p>
{% else %}
<p class="mt-3 text-lg font-bold text-gray-500">Free</p>
{% endif %}
</div>
<!-- Usage this period -->
{% if usage %}
<div class="md:col-span-2 bg-white rounded-xl shadow p-6">
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-widest mb-4">Usage</h2>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<!-- Lifetime -->
<div class="text-center">
<p class="text-3xl font-extrabold text-gray-900">{{ usage.lifetime }}</p>
<p class="text-xs text-gray-500 mt-1">Total files (lifetime)</p>
{% if tier.lifetime_file_limit > 0 %}
<div class="mt-2">
{% set lifetime_pct = ((usage.lifetime / tier.lifetime_file_limit) * 100) | int %}
<div class="w-full bg-gray-200 rounded-full h-1.5" role="progressbar" aria-valuemin="0"
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-valuemin="0"
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-valuemin="0"
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,70 @@
"""Add subscription_plans table
Revision ID: 015_add_subscription_plans
Revises: 014_add_subscription_tiers
Create Date: 2026-03-07
"""
from typing import Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "015_add_subscription_plans"
down_revision: Union[str, None] = "014_add_subscription_tiers"
depends_on: Union[str, None] = None
def upgrade() -> None:
"""Create subscription_plans table for admin-configurable plan definitions."""
op.create_table(
"subscription_plans",
sa.Column("id", sa.Integer(), primary_key=True, index=True, nullable=False),
sa.Column("plan_id", sa.String(50), unique=True, nullable=False, index=True),
sa.Column("name", sa.String(100), nullable=False),
sa.Column("tagline", sa.String(255), nullable=True),
# Pricing
sa.Column("price_monthly", sa.Float(), nullable=False, server_default="0.0"),
sa.Column("price_yearly", sa.Float(), nullable=False, server_default="0.0"),
sa.Column("trial_days", sa.Integer(), nullable=False, server_default="0"),
# Volume limits
sa.Column("lifetime_file_limit", sa.Integer(), nullable=False, server_default="0"),
sa.Column("daily_upload_limit", sa.Integer(), nullable=False, server_default="0"),
sa.Column("monthly_upload_limit", sa.Integer(), nullable=False, server_default="0"),
sa.Column("max_storage_destinations", sa.Integer(), nullable=False, server_default="0"),
sa.Column("max_ocr_pages_monthly", sa.Integer(), nullable=False, server_default="0"),
sa.Column("max_file_size_mb", sa.Integer(), nullable=False, server_default="0"),
sa.Column("max_mailboxes", sa.Integer(), nullable=False, server_default="0"),
# Overage
sa.Column("overage_percent", sa.Integer(), nullable=False, server_default="20"),
sa.Column("allow_overage_billing", sa.Boolean(), nullable=False, server_default="0"),
sa.Column("overage_price_per_doc", sa.Float(), nullable=True),
sa.Column("overage_price_per_ocr_page", sa.Float(), nullable=True),
# Display / marketing
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"),
sa.Column("is_highlighted", sa.Boolean(), nullable=False, server_default="0"),
sa.Column("badge_text", sa.String(50), nullable=True),
sa.Column("cta_text", sa.String(100), nullable=False, server_default="Get started"),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
sa.Column("features", sa.Text(), nullable=True),
sa.Column("api_access", sa.Boolean(), nullable=False, server_default="0"),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
),
)
def downgrade() -> None:
"""Drop subscription_plans table."""
op.drop_table("subscription_plans")
@@ -0,0 +1,45 @@
"""Add billing cycle and overage columns to user_profiles
Revision ID: 016_add_userprofile_billing
Revises: 015_add_subscription_plans
Create Date: 2026-03-07
"""
from typing import Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "016_add_userprofile_billing"
down_revision: Union[str, None] = "015_add_subscription_plans"
depends_on: Union[str, None] = None
def upgrade() -> None:
"""Add subscription_billing_cycle, subscription_period_start, allow_overage to user_profiles."""
op.add_column(
"user_profiles",
sa.Column(
"subscription_billing_cycle",
sa.String(10),
nullable=False,
server_default="monthly",
),
)
op.add_column(
"user_profiles",
sa.Column("subscription_period_start", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"user_profiles",
sa.Column("allow_overage", sa.Boolean(), nullable=False, server_default="0"),
)
def downgrade() -> None:
"""Remove billing columns from user_profiles."""
op.drop_column("user_profiles", "allow_overage")
op.drop_column("user_profiles", "subscription_period_start")
op.drop_column("user_profiles", "subscription_billing_cycle")
+487
View File
@@ -0,0 +1,487 @@
"""Unit tests for the subscription tier utility module."""
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import pytest
from app.utils.subscription import (
DEFAULT_TIER,
TIER_DEFAULTS,
TIER_ORDER,
TIERS,
QuotaExceeded,
_months_elapsed,
check_upload_allowed,
get_all_tiers,
get_tier,
get_user_tier_id,
get_user_usage,
)
# ---------------------------------------------------------------------------
# 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"] == 2.99
@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",
"trial_days",
"lifetime_file_limit",
"daily_upload_limit",
"monthly_upload_limit",
"max_storage_destinations",
"max_ocr_pages_monthly",
"max_file_size_mb",
"max_mailboxes",
"features",
"cta",
]
for tid, tier in TIERS.items():
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 of 50."""
assert TIERS["free"]["lifetime_file_limit"] == 50
@pytest.mark.unit
def test_free_tier_ocr_pages():
"""Free tier must have 150 OCR pages."""
assert TIERS["free"]["max_ocr_pages_monthly"] == 150
@pytest.mark.unit
def test_free_tier_has_no_mailboxes():
"""Free tier must not allow email ingestion mailboxes."""
assert TIERS["free"]["max_mailboxes"] == 0
@pytest.mark.unit
def test_business_tier_has_highest_limits():
"""Business tier must have the highest limits of all paid tiers."""
t = TIERS["business"]
# lifetime: no hard cap (0 = unlimited)
assert t["lifetime_file_limit"] == 0
# no daily cap (0 = unlimited)
assert t["daily_upload_limit"] == 0
assert t["monthly_upload_limit"] == 300
assert t["max_ocr_pages_monthly"] == 1500
# unlimited mailboxes (0 = unlimited)
assert t["max_mailboxes"] == 0
# unlimited file size (0 = unlimited)
assert t["max_file_size_mb"] == 0
@pytest.mark.unit
def test_mailbox_limits_increase_by_tier():
"""Mailbox limits must increase across tiers: free=0, starter=1, professional=3, business=0(inf)."""
assert TIERS["free"]["max_mailboxes"] == 0
assert TIERS["starter"]["max_mailboxes"] == 1
assert TIERS["professional"]["max_mailboxes"] == 3
assert TIERS["business"]["max_mailboxes"] == 0 # 0 means unlimited
@pytest.mark.unit
def test_paid_tiers_have_trial_days():
"""All paid tiers must have a 30-day free trial."""
for tid in ["starter", "professional", "business"]:
assert TIERS[tid]["trial_days"] == 30, f"Tier '{tid}' missing 30-day trial"
@pytest.mark.unit
def test_free_tier_has_no_trial():
assert TIERS["free"]["trial_days"] == 0
@pytest.mark.unit
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: raise QuotaExceeded at lifetime limit (50) with 0% buffer (exact enforcement)."""
db = MagicMock()
# Return None for both SubscriptionPlan lookup and UserProfile lookup
db.query.return_value.filter.return_value.first.return_value = None
with (
patch("app.utils.subscription.settings") as mock_settings,
patch("app.utils.subscription.get_lifetime_file_count", return_value=50),
):
mock_settings.subscription_overage_percent = 0
with pytest.raises(QuotaExceeded) as exc_info:
check_upload_allowed(db, "user@example.com", "free")
assert exc_info.value.limit_type == "lifetime"
assert exc_info.value.limit_value == 50
assert exc_info.value.current_value == 50
@pytest.mark.unit
def test_check_upload_passes_below_lifetime_limit():
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = None
with (
patch("app.utils.subscription.settings") as mock_settings,
patch("app.utils.subscription.get_lifetime_file_count", return_value=10),
):
mock_settings.subscription_overage_percent = 0
check_upload_allowed(db, "user@example.com", "free") # must not raise
@pytest.mark.unit
def test_check_upload_raises_when_monthly_exceeded():
"""Starter tier: raise QuotaExceeded when monthly limit (50) is hit (0% buffer)."""
db = MagicMock()
# UserProfile mock: no overage, monthly billing, no period_start
profile_mock = MagicMock()
profile_mock.allow_overage = False
profile_mock.subscription_billing_cycle = "monthly"
profile_mock.subscription_period_start = None
db.query.return_value.filter.return_value.first.side_effect = [None, profile_mock]
with (
patch("app.utils.subscription.settings") as mock_settings,
patch("app.utils.subscription.get_lifetime_file_count", return_value=0),
patch("app.utils.subscription.get_month_file_count", return_value=50),
):
mock_settings.subscription_overage_percent = 0
with pytest.raises(QuotaExceeded) as exc_info:
check_upload_allowed(db, "user@example.com", "starter")
assert exc_info.value.limit_type == "monthly"
@pytest.mark.unit
def test_check_upload_business_tier_within_limits():
"""Business tier: upload is allowed when count is below the monthly limit."""
db = MagicMock()
profile_mock = MagicMock()
profile_mock.allow_overage = False
profile_mock.subscription_billing_cycle = "monthly"
profile_mock.subscription_period_start = None
db.query.return_value.filter.return_value.first.side_effect = [None, profile_mock]
with (
patch("app.utils.subscription.settings") as mock_settings,
patch("app.utils.subscription.get_lifetime_file_count", return_value=0),
patch("app.utils.subscription.get_month_file_count", return_value=100),
):
mock_settings.subscription_overage_percent = 0
check_upload_allowed(db, "user@example.com", "business") # must not raise
# ---------------------------------------------------------------------------
# Overage buffer tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
def test_overage_percent_allows_buffer():
"""Starter monthly=50, 20% buffer -> enforce at 60. count=55 should pass, count=61 should raise."""
profile_mock = MagicMock()
profile_mock.allow_overage = False
profile_mock.subscription_billing_cycle = "monthly"
profile_mock.subscription_period_start = None
db = MagicMock()
db.query.return_value.filter.return_value.first.side_effect = [None, profile_mock]
with (
patch("app.utils.subscription.settings") as mock_settings,
patch("app.utils.subscription.get_lifetime_file_count", return_value=0),
patch("app.utils.subscription.get_month_file_count", return_value=55),
):
mock_settings.subscription_overage_percent = 20
# count=55 < 60 (50*1.20) -> should NOT raise
check_upload_allowed(db, "user@example.com", "starter")
# Reset mock for second call
db2 = MagicMock()
db2.query.return_value.filter.return_value.first.side_effect = [None, profile_mock]
with (
patch("app.utils.subscription.settings") as mock_settings,
patch("app.utils.subscription.get_lifetime_file_count", return_value=0),
patch("app.utils.subscription.get_month_file_count", return_value=61),
):
mock_settings.subscription_overage_percent = 20
# count=61 >= 60 -> should raise
with pytest.raises(QuotaExceeded) as exc_info:
check_upload_allowed(db2, "user@example.com", "starter")
assert exc_info.value.limit_type == "monthly"
assert exc_info.value.limit_value == 50
@pytest.mark.unit
def test_allow_overage_flag_bypasses_monthly_limit():
"""When allow_overage=True on UserProfile, monthly cap is never enforced."""
db = MagicMock()
profile_mock = MagicMock()
profile_mock.allow_overage = True
profile_mock.subscription_billing_cycle = "monthly"
profile_mock.subscription_period_start = None
db.query.return_value.filter.return_value.first.side_effect = [None, profile_mock]
with (
patch("app.utils.subscription.settings") as mock_settings,
patch("app.utils.subscription.get_lifetime_file_count", return_value=0),
patch("app.utils.subscription.get_month_file_count", return_value=999999),
):
mock_settings.subscription_overage_percent = 0
# Should NOT raise even with enormous count
check_upload_allowed(db, "user@example.com", "starter")
@pytest.mark.unit
def test_yearly_carryover_allows_accumulated_budget():
"""Yearly billing carry-over: period_start 2 months ago, monthly=50 (0% buffer).
Budget = 50 * months_elapsed. used=80 should pass; used at budget+1 should raise.
"""
db = MagicMock()
profile_mock = MagicMock()
profile_mock.allow_overage = False
profile_mock.subscription_billing_cycle = "yearly"
now = datetime.now(timezone.utc)
# period_start is 2 months before current month
if now.month > 2:
period_start = now.replace(month=now.month - 2, day=1)
else:
period_start = now.replace(year=now.year - 1, month=now.month + 10, day=1)
profile_mock.subscription_period_start = period_start
db.query.return_value.filter.return_value.first.side_effect = [None, profile_mock]
# months_elapsed with period 2 months ago = 3 (prev-prev, prev, current)
# budget = 50 * 3 = 150 with 0% buffer
with (
patch("app.utils.subscription.settings") as mock_settings,
patch("app.utils.subscription.get_lifetime_file_count", return_value=0),
patch("app.utils.subscription.get_year_file_count", return_value=80),
):
mock_settings.subscription_overage_percent = 0
# 80 < 150 -> should NOT raise
check_upload_allowed(db, "user@example.com", "starter")
# Reset mock for second call
db2 = MagicMock()
db2.query.return_value.filter.return_value.first.side_effect = [None, profile_mock]
with (
patch("app.utils.subscription.settings") as mock_settings,
patch("app.utils.subscription.get_lifetime_file_count", return_value=0),
patch("app.utils.subscription.get_year_file_count", return_value=151),
):
mock_settings.subscription_overage_percent = 0
# 151 >= 150 -> should raise
with pytest.raises(QuotaExceeded) as exc_info:
check_upload_allowed(db2, "user@example.com", "starter")
assert exc_info.value.limit_type == "monthly"
# ---------------------------------------------------------------------------
# _months_elapsed helper
# ---------------------------------------------------------------------------
@pytest.mark.unit
def test_months_elapsed_same_month():
now = datetime(2025, 6, 15, tzinfo=timezone.utc)
start = datetime(2025, 6, 1, tzinfo=timezone.utc)
assert _months_elapsed(start, now) == 1
@pytest.mark.unit
def test_months_elapsed_two_months():
now = datetime(2025, 8, 1, tzinfo=timezone.utc)
start = datetime(2025, 6, 1, tzinfo=timezone.utc)
assert _months_elapsed(start, now) == 3 # June, July, August = 3
@pytest.mark.unit
def test_months_elapsed_clamped_to_12():
now = datetime(2026, 6, 1, tzinfo=timezone.utc)
start = datetime(2024, 1, 1, tzinfo=timezone.utc)
assert _months_elapsed(start, now) == 12
# ---------------------------------------------------------------------------
# get_user_usage (mocked DB)
# ---------------------------------------------------------------------------
@pytest.mark.unit
def test_get_user_usage_returns_dict_with_correct_keys():
db = MagicMock()
# No profile -> monthly billing, no period_start
db.query.return_value.filter.return_value.first.return_value = None
with (
patch("app.utils.subscription.get_lifetime_file_count", return_value=10),
patch("app.utils.subscription.get_today_file_count", return_value=2),
patch("app.utils.subscription.get_month_file_count", return_value=8),
):
result = get_user_usage(db, "user@example.com")
assert result == {"lifetime": 10, "today": 2, "month": 8}
@pytest.mark.unit
def test_get_user_usage_includes_year_to_date_for_yearly():
"""Yearly subscriber gets year_to_date key in usage dict."""
db = MagicMock()
profile_mock = MagicMock()
profile_mock.subscription_billing_cycle = "yearly"
period_start = datetime(2025, 1, 1, tzinfo=timezone.utc)
profile_mock.subscription_period_start = period_start
db.query.return_value.filter.return_value.first.return_value = profile_mock
with (
patch("app.utils.subscription.get_lifetime_file_count", return_value=10),
patch("app.utils.subscription.get_today_file_count", return_value=2),
patch("app.utils.subscription.get_month_file_count", return_value=8),
patch("app.utils.subscription.get_year_file_count", return_value=40),
):
result = get_user_usage(db, "user@example.com")
assert "year_to_date" in result
assert result["year_to_date"] == 40
# ---------------------------------------------------------------------------
# TIERS / TIER_DEFAULTS alias
# ---------------------------------------------------------------------------
@pytest.mark.unit
def test_tiers_is_alias_for_tier_defaults():
"""TIERS must be the same object as TIER_DEFAULTS (backward compat alias)."""
assert TIERS is TIER_DEFAULTS
# ---------------------------------------------------------------------------
# API: /api/subscriptions/tiers (integration-style, mocked app)
# ---------------------------------------------------------------------------
@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