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:
@@ -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=<yourAuthentikAppClientID>
|
||||
AUTHENTIK_CLIENT_SECRET=<yourAuthentikClientSecret>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+13
@@ -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
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
@@ -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.
|
||||
|
||||
@@ -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=<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.
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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}`, {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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 & usage details</p>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/search" class="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 transition group">
|
||||
<span class="h-8 w-8 rounded-full bg-green-100 flex items-center justify-center flex-shrink-0 group-hover:bg-green-200">
|
||||
<i class="fas fa-search text-green-600 text-sm" aria-hidden="true"></i>
|
||||
</span>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-800">Search</p>
|
||||
<p class="text-xs text-gray-400">Full-text search across documents</p>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Processing stats -->
|
||||
<div class="bg-white rounded-xl shadow p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<i class="fas fa-chart-bar text-blue-500" aria-hidden="true"></i> Processing Stats
|
||||
</h2>
|
||||
<ul class="space-y-3 text-sm">
|
||||
<li class="flex items-center justify-between py-2 border-b border-gray-100">
|
||||
<span class="text-gray-600 flex items-center gap-2">
|
||||
<i class="fas fa-file-alt text-blue-400 w-4" aria-hidden="true"></i> Total processed
|
||||
</span>
|
||||
<span class="font-bold text-gray-900">{{ stats.processed_files | default(0) }}</span>
|
||||
</li>
|
||||
<li class="flex items-center justify-between py-2 border-b border-gray-100">
|
||||
<span class="text-gray-600 flex items-center gap-2">
|
||||
<i class="fas fa-eye text-indigo-400 w-4" aria-hidden="true"></i> Files with OCR
|
||||
</span>
|
||||
<span class="font-bold text-gray-900">{{ stats.files_with_ocr | default(0) }}</span>
|
||||
</li>
|
||||
<li class="flex items-center justify-between py-2 border-b border-gray-100">
|
||||
<span class="text-gray-600 flex items-center gap-2">
|
||||
<i class="fas fa-calendar-day text-green-400 w-4" aria-hidden="true"></i> Today
|
||||
</span>
|
||||
<span class="font-bold text-gray-900">{{ stats.files_today | default(0) }}</span>
|
||||
</li>
|
||||
<li class="flex items-center justify-between py-2">
|
||||
<span class="text-gray-600 flex items-center gap-2">
|
||||
<i class="fas fa-calendar-alt text-orange-400 w-4" aria-hidden="true"></i> This month
|
||||
</span>
|
||||
<span class="font-bold text-gray-900">{{ stats.files_month | default(0) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Upgrade CTA or Integrations -->
|
||||
{% if user_tier and user_tier.id != 'business' %}
|
||||
<div class="bg-gradient-to-br from-indigo-600 to-purple-700 rounded-xl shadow p-6 text-white flex flex-col justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold mb-2 flex items-center gap-2">
|
||||
<i class="fas fa-arrow-up-right-dots" aria-hidden="true"></i> Upgrade your plan
|
||||
</h2>
|
||||
<p class="text-indigo-100 text-sm mb-4">
|
||||
Unlock more documents, more destinations and priority support.
|
||||
</p>
|
||||
<ul class="space-y-1.5 text-sm text-indigo-100">
|
||||
<li class="flex items-center gap-2"><i class="fas fa-check-circle text-indigo-300" aria-hidden="true"></i> Higher daily & monthly limits</li>
|
||||
<li class="flex items-center gap-2"><i class="fas fa-check-circle text-indigo-300" aria-hidden="true"></i> More storage destinations</li>
|
||||
<li class="flex items-center gap-2"><i class="fas fa-check-circle text-indigo-300" aria-hidden="true"></i> More OCR pages</li>
|
||||
</ul>
|
||||
</div>
|
||||
<a href="/pricing"
|
||||
class="mt-6 inline-flex items-center justify-center bg-white text-indigo-700 font-semibold px-5 py-2.5 rounded-lg hover:bg-indigo-50 transition shadow">
|
||||
View plans & pricing <i class="fas fa-arrow-right ml-2 text-xs" aria-hidden="true"></i>
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="bg-white rounded-xl shadow p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||
<i class="fas fa-plug text-green-500" aria-hidden="true"></i> Integrations
|
||||
</h2>
|
||||
<div class="space-y-3 text-sm text-gray-600">
|
||||
<div class="flex items-center justify-between">
|
||||
<span>Active integrations</span>
|
||||
<span class="font-bold text-green-600">{{ stats.active_integrations | default(0) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>Storage targets</span>
|
||||
<span class="font-bold text-indigo-600">{{ stats.storage_targets | default(0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/status" class="mt-4 inline-flex items-center text-xs text-indigo-600 hover:text-indigo-800">
|
||||
View system status <i class="fas fa-arrow-right ml-1" aria-hidden="true"></i>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
{# ══════════════════════════════════════════════════════════════════════════ #}
|
||||
{# SINGLE-USER DASHBOARD (original layout, preserved as-is) #}
|
||||
{# ══════════════════════════════════════════════════════════════════════════ #}
|
||||
|
||||
<!-- Hero / Quick Actions -->
|
||||
<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 %}
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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")
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user