feat(subscriptions): database-backed plan designer with admin CRUD and overage buffer
- Add SubscriptionPlan model and subscription_plans table (migration 015) - Add billing cycle/period/allow_overage fields to UserProfile (migration 016) - Add subscription_overage_percent config field (replaces overage_factor) - Rewrite check_upload_allowed: use overage_percent, yearly carry-over, no daily cap - Add seed_default_plans(), _plan_to_dict(), get_year_file_count(), _months_elapsed() - Update get_tier/get_all_tiers to be DB-first with TIER_DEFAULTS fallback - Add TIER_DEFAULTS alias (TIERS kept for backward compat) - New /api/plans/ CRUD endpoints (admin-only except list/get) - New /admin/plans Plan Designer page with Alpine.js UI - Add Plan Designer link to admin navigation in base.html - Remove 'Files per day' row from pricing comparison table - Add billing cycle + period start to admin users edit modal - Seed default plans on startup in lifespan handler - Rewrite docs/SubscriptionTiers.md with full plan/overage/API docs - Fix all tests in test_subscription.py (remove daily cap tests, add overage/carry-over tests) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -17,6 +17,7 @@ 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
|
||||
@@ -58,3 +59,4 @@ 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
|
||||
@@ -55,6 +56,9 @@ class UserProfileUpsert(BaseModel):
|
||||
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):
|
||||
@@ -67,6 +71,9 @@ class UserProfileResponse(BaseModel):
|
||||
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
|
||||
|
||||
@@ -82,6 +89,9 @@ class UserSummary(BaseModel):
|
||||
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
|
||||
@@ -106,6 +116,11 @@ def _profile_to_dict(profile: UserProfile) -> dict[str, Any]:
|
||||
"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,
|
||||
}
|
||||
@@ -172,6 +187,13 @@ def list_users(
|
||||
"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,
|
||||
@@ -208,6 +230,11 @@ def get_user(user_id: str, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
|
||||
"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,
|
||||
@@ -235,6 +262,9 @@ 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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
+54
-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
|
||||
|
||||
@@ -204,5 +204,58 @@ class UserProfile(Base):
|
||||
# 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())
|
||||
|
||||
+162
-48
@@ -3,9 +3,9 @@ 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 — 5/day, 50/mo, 300 OCR pp/mo, 2 dests, 1 mailbox
|
||||
- professional $5.99/mo — 15/day, 150/mo, 750 OCR pp/mo, 5 dests, 3 mailboxes
|
||||
- business $7.99/mo — 30/day, 300/mo, 1500 OCR pp/mo, 10 dests, unlimited mailboxes
|
||||
- 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).
|
||||
@@ -34,13 +34,15 @@ from typing import Any
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tier catalogue
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TIERS: dict[str, dict[str, Any]] = {
|
||||
TIER_DEFAULTS: dict[str, dict[str, Any]] = {
|
||||
"free": {
|
||||
"id": "free",
|
||||
"name": "Free",
|
||||
@@ -161,6 +163,9 @@ TIERS: dict[str, dict[str, Any]] = {
|
||||
},
|
||||
}
|
||||
|
||||
# Backward-compatible alias
|
||||
TIERS = TIER_DEFAULTS
|
||||
|
||||
# Display order for the pricing page
|
||||
TIER_ORDER = ["free", "starter", "professional", "business"]
|
||||
|
||||
@@ -168,19 +173,135 @@ TIER_ORDER = ["free", "starter", "professional", "business"]
|
||||
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) -> dict[str, Any]:
|
||||
"""Return tier config dict; falls back to *free* for unknown ids."""
|
||||
return TIERS.get(tier_id, TIERS["free"])
|
||||
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() -> list[dict[str, Any]]:
|
||||
"""Return tiers in display order."""
|
||||
return [TIERS[tid] for tid in TIER_ORDER]
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -235,11 +356,7 @@ def get_month_file_count(db: Session, owner_id: str) -> int:
|
||||
|
||||
|
||||
def get_year_file_count(db: Session, owner_id: str, period_start: datetime) -> int:
|
||||
"""Files processed since the start of the current subscription period.
|
||||
|
||||
Used for yearly-subscription carry-over: compares cumulative usage against the
|
||||
cumulative monthly budget since the annual period started.
|
||||
"""
|
||||
"""Files processed since the start of the current annual subscription period."""
|
||||
from app.models import FileRecord
|
||||
|
||||
return _scalar_count(
|
||||
@@ -252,7 +369,7 @@ def get_year_file_count(db: Session, owner_id: str, period_start: datetime) -> i
|
||||
|
||||
|
||||
def _months_elapsed(period_start: datetime, now: datetime) -> int:
|
||||
"""Calendar months elapsed since *period_start*, clamped to 1–12."""
|
||||
"""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))
|
||||
|
||||
@@ -275,37 +392,33 @@ class QuotaExceeded(Exception):
|
||||
def check_upload_allowed(db: Session, owner_id: str | None, tier_id: str | None) -> None:
|
||||
"""Raise :class:`QuotaExceeded` if this user is not allowed to upload another file.
|
||||
|
||||
When *owner_id* or *tier_id* is ``None`` (e.g. single-user mode) the check
|
||||
is skipped entirely.
|
||||
Skipped entirely when *owner_id* or *tier_id* is ``None`` (single-user mode).
|
||||
|
||||
Enforcement model
|
||||
-----------------
|
||||
* **Announced limit** — the quota shown to users on the pricing page
|
||||
(``monthly_upload_limit`` in TIERS).
|
||||
* **Enforcement limit** — ``announced × settings.subscription_overage_factor``
|
||||
(default 1.33). A 150-doc/month plan is therefore enforced at 200 docs,
|
||||
giving users a soft buffer before they see an error.
|
||||
* **Overage flag** — if ``UserProfile.allow_overage`` is ``True`` the check
|
||||
is bypassed entirely. Usage is still tracked so future billing can charge
|
||||
for overages. (Not yet exposed in the admin UI.)
|
||||
* **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``.
|
||||
effective limit = monthly_limit × months_elapsed × overage_factor.
|
||||
Unused quota from earlier months rolls forward automatically.
|
||||
|
||||
No daily cap is enforced — ``daily_upload_limit`` in TIERS is kept as
|
||||
informational data only.
|
||||
* **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)
|
||||
tier = get_tier(tier_id, db)
|
||||
|
||||
# Resolve overage factor from config
|
||||
from app.config import settings
|
||||
# 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
|
||||
|
||||
overage_factor: float = settings.subscription_overage_factor
|
||||
|
||||
# Fetch profile for billing cycle and overage permission
|
||||
from app.models import UserProfile
|
||||
|
||||
profile = db.query(UserProfile).filter(UserProfile.user_id == owner_id).first()
|
||||
@@ -314,7 +427,7 @@ def check_upload_allowed(db: Session, owner_id: str | None, tier_id: str | None)
|
||||
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 = tier["lifetime_file_limit"]
|
||||
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)
|
||||
@@ -327,14 +440,13 @@ def check_upload_allowed(db: Session, owner_id: str | None, tier_id: str | None)
|
||||
current_value=count,
|
||||
)
|
||||
|
||||
# 2. Monthly cap — skipped entirely when overage is enabled for this user
|
||||
# 2. Monthly cap — bypassed when allow_overage is True (future billing)
|
||||
if allow_overage:
|
||||
return
|
||||
|
||||
monthly_limit = tier["monthly_upload_limit"]
|
||||
monthly_limit: int = tier["monthly_upload_limit"]
|
||||
if monthly_limit > 0:
|
||||
if billing_cycle == "yearly" and period_start is not None:
|
||||
# Carry-over: cumulative usage vs cumulative budget within the subscription year
|
||||
now = datetime.now(timezone.utc)
|
||||
months = _months_elapsed(period_start, now)
|
||||
cumulative_budget = int(monthly_limit * months * overage_factor)
|
||||
@@ -342,14 +454,13 @@ def check_upload_allowed(db: Session, owner_id: str | None, tier_id: str | None)
|
||||
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 will reset on your "
|
||||
"annual renewal date, or you can upgrade your plan.",
|
||||
"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:
|
||||
# Monthly billing: check current calendar month only
|
||||
count = get_month_file_count(db, owner_id)
|
||||
enforcement_limit = int(monthly_limit * overage_factor)
|
||||
if count >= enforcement_limit:
|
||||
@@ -360,9 +471,6 @@ def check_upload_allowed(db: Session, owner_id: str | None, tier_id: str | None)
|
||||
limit_value=monthly_limit,
|
||||
current_value=count,
|
||||
)
|
||||
limit_value=monthly_limit,
|
||||
current_value=count,
|
||||
)
|
||||
|
||||
|
||||
def get_user_tier_id(db: Session, owner_id: str | None) -> str:
|
||||
@@ -378,9 +486,15 @@ def get_user_tier_id(db: Session, owner_id: str | None) -> str:
|
||||
|
||||
|
||||
def get_user_usage(db: Session, owner_id: str) -> dict[str, int]:
|
||||
"""Return a dict with lifetime / daily / monthly file counts for *owner_id*."""
|
||||
return {
|
||||
"""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,11 +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
|
||||
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
|
||||
@@ -37,3 +38,4 @@ 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
|
||||
|
||||
@@ -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})
|
||||
@@ -18,9 +18,9 @@ router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/pricing", include_in_schema=False)
|
||||
async def pricing_page(request: Request):
|
||||
async def pricing_page(request: Request, db: Session = Depends(get_db)):
|
||||
"""Public-facing pricing and plans page."""
|
||||
tiers = get_all_tiers()
|
||||
tiers = get_all_tiers(db)
|
||||
return templates.TemplateResponse(
|
||||
"pricing.html",
|
||||
{
|
||||
@@ -47,8 +47,8 @@ async def my_subscription_page(request: Request, db: Session = Depends(get_db)):
|
||||
tier_id = "business"
|
||||
usage = None
|
||||
|
||||
tier = get_tier(tier_id)
|
||||
all_tiers = get_all_tiers()
|
||||
tier = get_tier(tier_id, db)
|
||||
all_tiers = get_all_tiers(db)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"subscription.html",
|
||||
|
||||
Reference in New Issue
Block a user