diff --git a/.env.demo b/.env.demo index a5bb7f16..7fe4cd67 100644 --- a/.env.demo +++ b/.env.demo @@ -141,6 +141,12 @@ UNOWNED_DOCS_VISIBLE_TO_ALL=true # Leave empty/unset to keep them unowned until claimed. # DEFAULT_OWNER_ID= +# **Subscription / Quota Settings** +# Soft-limit overage buffer in percent (0–200). Announced quota is multiplied by (1 + percent/100) +# for actual enforcement. E.g. 20 means a 150-doc/month plan enforces at 180. 0 = enforce exactly. +# Per-plan overage_percent set in the Plan Designer overrides this global default. +# SUBSCRIPTION_OVERAGE_PERCENT=20 + # **OpenID Connect/Authentik Settings** AUTHENTIK_CLIENT_ID= AUTHENTIK_CLIENT_SECRET= diff --git a/app/api/__init__.py b/app/api/__init__.py index 91872ecc..ac916de2 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -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) diff --git a/app/api/admin_users.py b/app/api/admin_users.py index 846cf09c..f80d463f 100644 --- a/app/api/admin_users.py +++ b/app/api/admin_users.py @@ -5,6 +5,7 @@ administrators can inspect, configure, and manage users in multi-user mode. """ import logging +from datetime import datetime from typing import Annotated, Any from fastapi import APIRouter, Depends, HTTPException, Query, Request, status @@ -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() diff --git a/app/api/files.py b/app/api/files.py index b8ce40f8..87586664 100644 --- a/app/api/files.py +++ b/app/api/files.py @@ -11,7 +11,7 @@ import zipfile from datetime import datetime, timezone from typing import Annotated, List, Optional -from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile +from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile, status from fastapi.responses import StreamingResponse from sqlalchemy import asc, desc from sqlalchemy.orm import Session @@ -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" diff --git a/app/api/plans.py b/app/api/plans.py new file mode 100644 index 00000000..844b9326 --- /dev/null +++ b/app/api/plans.py @@ -0,0 +1,273 @@ +"""REST API for subscription plan CRUD. + +Endpoints: + GET /api/plans/ — list active plans (public) + GET /api/plans/admin — list all plans inc. inactive (admin only) + POST /api/plans/ — create plan (admin only) + GET /api/plans/{plan_id} — get single active plan (public) + PUT /api/plans/{plan_id} — update plan (admin only) + DELETE /api/plans/{plan_id} — delete plan (admin only) + POST /api/plans/seed — seed default plans (admin only) + POST /api/plans/reorder — set sort_order for multiple plans (admin only) +""" + +import json +import logging +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models import SubscriptionPlan + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/plans", tags=["plans"]) + +DbSession = Annotated[Session, Depends(get_db)] + + +# --------------------------------------------------------------------------- +# Auth helper (admin-only) +# --------------------------------------------------------------------------- + + +def _require_admin(request: Request) -> dict: + """Ensure the caller is an admin. Raises 403 otherwise.""" + user = request.session.get("user") + if not user or not user.get("is_admin"): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required") + return user + + +AdminUser = Annotated[dict, Depends(_require_admin)] + + +# --------------------------------------------------------------------------- +# Pydantic schemas +# --------------------------------------------------------------------------- + + +class PlanUpsert(BaseModel): + """Body for creating or updating a subscription plan.""" + + name: str + tagline: str | None = None + price_monthly: float = 0.0 + price_yearly: float = 0.0 + trial_days: int = 0 + lifetime_file_limit: int = 0 + daily_upload_limit: int = 0 + monthly_upload_limit: int = 0 + max_storage_destinations: int = 0 + max_ocr_pages_monthly: int = 0 + max_file_size_mb: int = 0 + max_mailboxes: int = 0 + overage_percent: int = Field(default=20, ge=0, le=200) + allow_overage_billing: bool = False + overage_price_per_doc: float | None = None + overage_price_per_ocr_page: float | None = None + is_active: bool = True + is_highlighted: bool = False + badge_text: str | None = None + cta_text: str = "Get started" + sort_order: int = 0 + features: list[str] = [] + api_access: bool = False + + +class ReorderBody(BaseModel): + """Body for reordering plans.""" + + order: list[str] + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _plan_to_response(plan: SubscriptionPlan) -> dict[str, Any]: + features: list[str] = [] + if plan.features: + try: + features = json.loads(plan.features) + except (json.JSONDecodeError, TypeError): + features = [] + return { + "id": plan.id, + "plan_id": plan.plan_id, + "name": plan.name, + "tagline": plan.tagline, + "price_monthly": plan.price_monthly, + "price_yearly": plan.price_yearly, + "trial_days": plan.trial_days, + "lifetime_file_limit": plan.lifetime_file_limit, + "daily_upload_limit": plan.daily_upload_limit, + "monthly_upload_limit": plan.monthly_upload_limit, + "max_storage_destinations": plan.max_storage_destinations, + "max_ocr_pages_monthly": plan.max_ocr_pages_monthly, + "max_file_size_mb": plan.max_file_size_mb, + "max_mailboxes": plan.max_mailboxes, + "overage_percent": plan.overage_percent, + "allow_overage_billing": plan.allow_overage_billing, + "overage_price_per_doc": plan.overage_price_per_doc, + "overage_price_per_ocr_page": plan.overage_price_per_ocr_page, + "is_active": plan.is_active, + "is_highlighted": plan.is_highlighted, + "badge_text": plan.badge_text, + "cta_text": plan.cta_text, + "sort_order": plan.sort_order, + "features": features, + "api_access": plan.api_access, + "created_at": plan.created_at.isoformat() if plan.created_at else None, + "updated_at": plan.updated_at.isoformat() if plan.updated_at else None, + } + + +def _apply_body(plan: SubscriptionPlan, body: PlanUpsert) -> None: + """Apply PlanUpsert fields onto a SubscriptionPlan ORM object.""" + plan.name = body.name + plan.tagline = body.tagline + plan.price_monthly = body.price_monthly + plan.price_yearly = body.price_yearly + plan.trial_days = body.trial_days + plan.lifetime_file_limit = body.lifetime_file_limit + plan.daily_upload_limit = body.daily_upload_limit + plan.monthly_upload_limit = body.monthly_upload_limit + plan.max_storage_destinations = body.max_storage_destinations + plan.max_ocr_pages_monthly = body.max_ocr_pages_monthly + plan.max_file_size_mb = body.max_file_size_mb + plan.max_mailboxes = body.max_mailboxes + plan.overage_percent = body.overage_percent + plan.allow_overage_billing = body.allow_overage_billing + plan.overage_price_per_doc = body.overage_price_per_doc + plan.overage_price_per_ocr_page = body.overage_price_per_ocr_page + plan.is_active = body.is_active + plan.is_highlighted = body.is_highlighted + plan.badge_text = body.badge_text + plan.cta_text = body.cta_text + plan.sort_order = body.sort_order + plan.features = json.dumps(body.features) + plan.api_access = body.api_access + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + + +@router.get("/", summary="List active plans (public)") +def list_active_plans(db: DbSession) -> dict[str, Any]: + """Return all active plans in sort order. Public endpoint — no auth required.""" + plans = ( + db.query(SubscriptionPlan) + .filter(SubscriptionPlan.is_active.is_(True)) + .order_by(SubscriptionPlan.sort_order) + .all() + ) + return {"plans": [_plan_to_response(p) for p in plans]} + + +@router.get("/admin", summary="List all plans including inactive (admin only)") +def list_all_plans(db: DbSession, _admin: AdminUser) -> dict[str, Any]: + """Return all plans (active and inactive) in sort order. Admin only.""" + plans = db.query(SubscriptionPlan).order_by(SubscriptionPlan.sort_order).all() + return {"plans": [_plan_to_response(p) for p in plans]} + + +@router.post("/seed", summary="Seed default plans (admin only)", status_code=status.HTTP_200_OK) +def seed_plans(db: DbSession, _admin: AdminUser) -> dict[str, Any]: + """Seed the subscription_plans table from TIER_DEFAULTS. No-op if plans already exist.""" + from app.utils.subscription import seed_default_plans + + inserted = seed_default_plans(db) + return {"inserted": inserted, "message": f"Seeded {inserted} default plan(s)."} + + +@router.post("/reorder", summary="Reorder plans (admin only)") +def reorder_plans(body: ReorderBody, db: DbSession, _admin: AdminUser) -> dict[str, Any]: + """Update sort_order for each plan_id in *body.order* (position = index in list).""" + updated = 0 + for sort_order, plan_id in enumerate(body.order): + plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.plan_id == plan_id).first() + if plan: + plan.sort_order = sort_order + updated += 1 + try: + db.commit() + except Exception: + db.rollback() + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to reorder plans") + return {"updated": updated} + + +@router.post("/", summary="Create a new plan (admin only)", status_code=status.HTTP_201_CREATED) +def create_plan(plan_id: str, body: PlanUpsert, db: DbSession, _admin: AdminUser) -> dict[str, Any]: + """Create a new subscription plan with the given *plan_id* slug.""" + existing = db.query(SubscriptionPlan).filter(SubscriptionPlan.plan_id == plan_id).first() + if existing: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Plan '{plan_id}' already exists.", + ) + plan = SubscriptionPlan(plan_id=plan_id) + _apply_body(plan, body) + db.add(plan) + try: + db.commit() + db.refresh(plan) + except Exception: + db.rollback() + raise + logger.info("Admin created subscription plan '%s'", plan_id) + return _plan_to_response(plan) + + +@router.get("/{plan_id}", summary="Get a single active plan (public)") +def get_plan(plan_id: str, db: DbSession) -> dict[str, Any]: + """Return a single active plan by plan_id. Public endpoint.""" + plan = ( + db.query(SubscriptionPlan) + .filter( + SubscriptionPlan.plan_id == plan_id, + SubscriptionPlan.is_active.is_(True), + ) + .first() + ) + if not plan: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Plan '{plan_id}' not found.") + return _plan_to_response(plan) + + +@router.put("/{plan_id}", summary="Update an existing plan (admin only)") +def update_plan(plan_id: str, body: PlanUpsert, db: DbSession, _admin: AdminUser) -> dict[str, Any]: + """Update an existing subscription plan. Admin only.""" + plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.plan_id == plan_id).first() + if not plan: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Plan '{plan_id}' not found.") + _apply_body(plan, body) + try: + db.commit() + db.refresh(plan) + except Exception: + db.rollback() + raise + logger.info("Admin updated subscription plan '%s'", plan_id) + return _plan_to_response(plan) + + +@router.delete("/{plan_id}", summary="Delete a plan (admin only)", status_code=status.HTTP_204_NO_CONTENT) +def delete_plan(plan_id: str, db: DbSession, _admin: AdminUser) -> None: + """Delete a subscription plan. Admin only.""" + plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.plan_id == plan_id).first() + if not plan: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Plan '{plan_id}' not found.") + try: + db.delete(plan) + db.commit() + except Exception: + db.rollback() + raise + logger.info("Admin deleted subscription plan '%s'", plan_id) diff --git a/app/api/subscriptions.py b/app/api/subscriptions.py new file mode 100644 index 00000000..031d5527 --- /dev/null +++ b/app/api/subscriptions.py @@ -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(), + } diff --git a/app/config.py b/app/config.py index b678a97e..d0322e82 100644 --- a/app/config.py +++ b/app/config.py @@ -153,6 +153,19 @@ class Settings(BaseSettings): ), ) + subscription_overage_percent: int = Field( + default=20, + ge=0, + le=200, + description=( + "Soft-limit overage buffer in percent (0–200). The announced monthly quota is " + "increased by this percentage for actual enforcement. E.g. 20 means a 150-doc/month " + "plan enforces at 180 docs (150 × 1.20). Set 0 to enforce exactly at the announced " + "limit. Per-plan overage_percent (set in Plan Designer) overrides this global default. " + "Default: 20." + ), + ) + # Authentik authentik_client_id: Optional[str] = None authentik_client_secret: Optional[str] = None diff --git a/app/main.py b/app/main.py index 10f305d2..5f39ae2c 100644 --- a/app/main.py +++ b/app/main.py @@ -99,6 +99,19 @@ async def lifespan(app: FastAPI): # Send startup notification notify_startup() + # Seed default subscription plans if none exist + try: + from app.database import SessionLocal as _SessionLocal + from app.utils.subscription import seed_default_plans as _seed_plans + + _db_seed = _SessionLocal() + try: + _seed_plans(_db_seed) + finally: + _db_seed.close() + except Exception: + logging.debug("Subscription plan seeding skipped — DB may not be ready yet") # noqa: S110 + # Application is now running yield diff --git a/app/models.py b/app/models.py index 3d19c787..b5857bb5 100644 --- a/app/models.py +++ b/app/models.py @@ -1,6 +1,6 @@ # app/models.py -from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func +from sqlalchemy import Boolean, Column, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func from app.database import Base @@ -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()) diff --git a/app/utils/settings_service.py b/app/utils/settings_service.py index 9c79e2d8..c11e64d6 100644 --- a/app/utils/settings_service.py +++ b/app/utils/settings_service.py @@ -1771,6 +1771,19 @@ SETTING_METADATA = { "required": False, "restart_required": True, }, + "subscription_overage_percent": { + "category": "Subscriptions", + "description": ( + "Soft-limit overage buffer in percent (0–200). The announced monthly quota is increased by this " + "percentage for actual enforcement. For example, 20 means a 150-doc/month plan enforces at 180 docs " + "(150 × 1.20). Set to 0 to enforce exactly at the announced limit. Per-plan overage_percent configured " + "in the Plan Designer overrides this global default. Default: 20." + ), + "type": "integer", + "sensitive": False, + "required": False, + "restart_required": False, + }, } diff --git a/app/utils/subscription.py b/app/utils/subscription.py new file mode 100644 index 00000000..6f7ea841 --- /dev/null +++ b/app/utils/subscription.py @@ -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 diff --git a/app/views/__init__.py b/app/views/__init__.py index 9570ee7f..75eafbfb 100644 --- a/app/views/__init__.py +++ b/app/views/__init__.py @@ -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 diff --git a/app/views/general.py b/app/views/general.py index e6d70f25..8a295f55 100644 --- a/app/views/general.py +++ b/app/views/general.py @@ -2,11 +2,12 @@ General routes for the application homepage and basic pages. """ -from datetime import date +from datetime import date, datetime, timezone from pathlib import Path from fastapi import Depends, HTTPException, Request from fastapi.responses import FileResponse, RedirectResponse +from sqlalchemy import func from sqlalchemy.orm import Session from app.utils.config_validator import get_provider_status, validate_storage_configs @@ -54,25 +55,76 @@ async def serve_index(request: Request, db: Session = Depends(get_db)): and provider in ["dropbox", "nextcloud", "sftp", "s3", "ftp", "webdav", "google_drive", "onedrive"] ) - # Query the actual file count from the database - processed_files = 0 + from app.models import FileRecord + + today = datetime.now(timezone.utc).date() + + # Global file counts (or per-user in multi-user mode) + from app.config import settings + + user = request.session.get("user") or {} + is_admin = user.get("is_admin", False) + try: - # Import the model here to avoid circular imports - from app.models import FileRecord + total_files: int = db.query(func.count(FileRecord.id)).scalar() or 0 - processed_files = db.query(FileRecord.id).count() + files_today: int = ( + db.query(func.count(FileRecord.id)).filter(func.date(FileRecord.created_at) == today).scalar() or 0 + ) + + files_month: int = ( + db.query(func.count(FileRecord.id)) + .filter(func.strftime("%Y-%m", FileRecord.created_at) == today.strftime("%Y-%m")) + .scalar() + or 0 + ) + + files_with_ocr: int = db.query(func.count(FileRecord.id)).filter(FileRecord.ocr_text.isnot(None)).scalar() or 0 + + unique_users: int = ( + db.query(func.count(func.distinct(FileRecord.owner_id))).filter(FileRecord.owner_id.isnot(None)).scalar() + or 0 + ) except Exception as e: - # Log error but continue (don't break the page if DB query fails) - logger.error(f"Error counting files: {str(e)}") + logger.error(f"Error computing dashboard stats: {e}") + total_files = files_today = files_month = files_with_ocr = unique_users = 0 + + # Per-user usage for the subscription widget (multi-user only) + user_usage = None + user_tier = None + if settings.multi_user_enabled: + owner_id: str = user.get("username") or user.get("email") or user.get("sub") or "" + if owner_id: + try: + from app.utils.subscription import get_tier, get_user_tier_id, get_user_usage + + tier_id = get_user_tier_id(db, owner_id) + user_tier = get_tier(tier_id) + user_usage = get_user_usage(db, owner_id) + except Exception as e: + logger.error(f"Error fetching subscription info: {e}") - # Create stats object to pass to the template stats = { - "processed_files": processed_files, + "processed_files": total_files, + "files_today": files_today, + "files_month": files_month, + "files_with_ocr": files_with_ocr, + "unique_users": unique_users, "active_integrations": configured_providers, "storage_targets": configured_storage_targets, } - return templates.TemplateResponse("index.html", {"request": request, "stats": stats}) + return templates.TemplateResponse( + "index.html", + { + "request": request, + "stats": stats, + "user_usage": user_usage, + "user_tier": user_tier, + "multi_user_enabled": settings.multi_user_enabled, + "is_admin": is_admin, + }, + ) @router.get("/about", include_in_schema=False) diff --git a/app/views/plans.py b/app/views/plans.py new file mode 100644 index 00000000..05c00451 --- /dev/null +++ b/app/views/plans.py @@ -0,0 +1,18 @@ +"""View route for the admin Plan Designer page.""" + +from fastapi import Request +from fastapi.responses import HTMLResponse +from fastapi.routing import APIRouter +from fastapi.templating import Jinja2Templates + +from app.auth import require_login + +router = APIRouter() +templates = Jinja2Templates(directory="frontend/templates") + + +@router.get("/admin/plans", response_class=HTMLResponse) +@require_login +async def plan_designer(request: Request) -> HTMLResponse: + """Admin Plan Designer page.""" + return templates.TemplateResponse("admin_plans.html", {"request": request}) diff --git a/app/views/subscriptions.py b/app/views/subscriptions.py new file mode 100644 index 00000000..f30e8837 --- /dev/null +++ b/app/views/subscriptions.py @@ -0,0 +1,64 @@ +"""View routes for subscription-related pages. + +Routes: + GET /pricing — public marketing pricing page + GET /subscription — authenticated user's current plan & usage +""" + +import logging + +from fastapi import Depends, Request +from sqlalchemy.orm import Session + +from app.utils.subscription import TIER_ORDER, get_all_tiers, get_tier, get_user_tier_id, get_user_usage +from app.views.base import APIRouter, get_db, require_login, templates + +logger = logging.getLogger(__name__) +router = APIRouter() + + +@router.get("/pricing", include_in_schema=False) +async def pricing_page(request: Request, 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, + }, + ) diff --git a/docs/ConfigurationGuide.md b/docs/ConfigurationGuide.md index 5c4dc3a0..863afb09 100644 --- a/docs/ConfigurationGuide.md +++ b/docs/ConfigurationGuide.md @@ -195,6 +195,16 @@ Admins can assign ownership of documents to any user: The `DEFAULT_OWNER_ID` setting can also be configured via the Settings page, which provides an autocomplete field that searches existing users by substring. +### Subscriptions & Upload Quotas + +DocuElevate supports configurable subscription plans with per-user upload quotas enforced at upload time. +Plans are managed via the **Plan Designer** at `/admin/plans`. The following global setting controls the +default overage buffer applied across all plans. + +| **Variable** | **Description** | **Default** | +|----------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------| +| `SUBSCRIPTION_OVERAGE_PERCENT` | Soft-limit overage buffer in percent (0–200). The announced monthly quota is multiplied by `(1 + percent/100)` for actual enforcement. E.g. `20` means a 150-doc/month plan enforces at 180 docs (150 × 1.20). Set `0` to enforce exactly at the announced limit. Per-plan `overage_percent` configured in the Plan Designer overrides this global default. | `20` | + ### Security Headers DocuElevate supports HTTP security headers to improve browser-side security. **These headers are disabled by default** since most deployments use a reverse proxy (Traefik, Nginx, etc.) that already adds them. Enable only if deploying directly without a reverse proxy. See [Deployment Guide - Security Headers](DeploymentGuide.md#security-headers) for detailed configuration examples. diff --git a/docs/SubscriptionTiers.md b/docs/SubscriptionTiers.md new file mode 100644 index 00000000..d013fff7 --- /dev/null +++ b/docs/SubscriptionTiers.md @@ -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: 0–200), 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 1–2, 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 (0–100%). 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=` | 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 (0–200). Per-plan setting overrides this. | + +See `docs/ConfigurationGuide.md` for all available settings. diff --git a/frontend/static/js/common.js b/frontend/static/js/common.js index 2d9dd486..b6bc6019 100644 --- a/frontend/static/js/common.js +++ b/frontend/static/js/common.js @@ -141,25 +141,35 @@ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', fun if (authSection) { authSection.textContent = ''; // Clear existing content const container = document.createElement('div'); - container.className = 'flex items-center'; + container.className = 'flex items-center gap-2'; const img = document.createElement('img'); img.src = data.picture; img.alt = 'Avatar'; - img.className = 'w-8 h-8 rounded-full mr-2'; + img.className = 'w-8 h-8 rounded-full'; const span = document.createElement('span'); span.textContent = displayName; + + // Subscription link + const planLink = document.createElement('a'); + planLink.href = '/subscription'; + planLink.className = 'text-xs text-indigo-600 hover:text-indigo-800 font-medium hidden md:inline'; + planLink.title = 'My subscription'; + const planIcon = document.createElement('i'); + planIcon.className = 'fas fa-layer-group'; + planLink.appendChild(planIcon); const logoutLink = document.createElement('a'); logoutLink.href = '/logout'; - logoutLink.className = 'ml-3 text-red-600 hover:text-red-800'; + logoutLink.className = 'text-red-600 hover:text-red-800'; const icon = document.createElement('i'); icon.className = 'fas fa-sign-out-alt'; logoutLink.appendChild(icon); container.appendChild(img); container.appendChild(span); + container.appendChild(planLink); container.appendChild(logoutLink); authSection.appendChild(container); } diff --git a/frontend/templates/admin_plans.html b/frontend/templates/admin_plans.html new file mode 100644 index 00000000..e4e4cc6b --- /dev/null +++ b/frontend/templates/admin_plans.html @@ -0,0 +1,607 @@ +{% extends "base.html" %} + +{% block title %}Plan Designer — DocuElevate Admin{% endblock %} + +{% block content %} +
+ + +
+
+

Plan Designer

+

Manage subscription plans shown on the public pricing page.

+
+
+ + +
+
+ + +
+

About the Overage Buffer

+

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

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

Loading plans…

+
+ + +
+ + + + + + + + + + + + + + + + + +
OrderPlanMonthlyYearlyMonthly LimitOverage %ActiveActions
+
+ + +
+ +
+ + + + +
+ + +{% endblock %} diff --git a/frontend/templates/admin_users.html b/frontend/templates/admin_users.html index f720348a..84ed2042 100644 --- a/frontend/templates/admin_users.html +++ b/frontend/templates/admin_users.html @@ -68,6 +68,7 @@ Display Name Documents Last Upload + Plan Upload Limit Status Actions @@ -76,14 +77,14 @@