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:
copilot-swe-agent[bot]
2026-03-06 18:48:27 +00:00
parent d439d9afdd
commit ea7fffa3a1
18 changed files with 1653 additions and 235 deletions
+2
View File
@@ -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.logs import router as logs_router
from app.api.onedrive import router as onedrive_router from app.api.onedrive import router as onedrive_router
from app.api.openai import router as openai_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.process import router as process_router
from app.api.queue import router as queue_router from app.api.queue import router as queue_router
from app.api.saved_searches import router as saved_searches_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(webhooks_router)
router.include_router(database_router) router.include_router(database_router)
router.include_router(subscriptions_router) router.include_router(subscriptions_router)
router.include_router(plans_router)
+30
View File
@@ -5,6 +5,7 @@ administrators can inspect, configure, and manage users in multi-user mode.
""" """
import logging import logging
from datetime import datetime
from typing import Annotated, Any from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
@@ -55,6 +56,9 @@ class UserProfileUpsert(BaseModel):
default="free", default="free",
description="Subscription tier: free | starter | professional | business", 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): class UserProfileResponse(BaseModel):
@@ -67,6 +71,9 @@ class UserProfileResponse(BaseModel):
notes: str | None notes: str | None
is_blocked: bool is_blocked: bool
subscription_tier: str | None subscription_tier: str | None
subscription_billing_cycle: str
subscription_period_start: str | None
allow_overage: bool
created_at: str | None created_at: str | None
updated_at: str | None updated_at: str | None
@@ -82,6 +89,9 @@ class UserSummary(BaseModel):
notes: str | None notes: str | None
is_blocked: bool is_blocked: bool
subscription_tier: str | None subscription_tier: str | None
subscription_billing_cycle: str | None
subscription_period_start: str | None
allow_overage: bool
profile_id: int | None profile_id: int | None
document_count: int document_count: int
last_upload: str | None last_upload: str | None
@@ -106,6 +116,11 @@ def _profile_to_dict(profile: UserProfile) -> dict[str, Any]:
"notes": profile.notes, "notes": profile.notes,
"is_blocked": profile.is_blocked, "is_blocked": profile.is_blocked,
"subscription_tier": profile.subscription_tier or "free", "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, "created_at": profile.created_at.isoformat() if profile.created_at else None,
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None, "updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
} }
@@ -172,6 +187,13 @@ def list_users(
"notes": profile.notes if profile else None, "notes": profile.notes if profile else None,
"is_blocked": profile.is_blocked if profile else False, "is_blocked": profile.is_blocked if profile else False,
"subscription_tier": (profile.subscription_tier or "free") if profile else "free", "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, "profile_id": profile.id if profile else None,
"document_count": doc_row.doc_count if doc_row else 0, "document_count": doc_row.doc_count if doc_row else 0,
"last_upload": doc_row.last_upload.isoformat() if (doc_row and doc_row.last_upload) else None, "last_upload": doc_row.last_upload.isoformat() if (doc_row and doc_row.last_upload) else None,
@@ -208,6 +230,11 @@ def get_user(user_id: str, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
"notes": profile.notes if profile else None, "notes": profile.notes if profile else None,
"is_blocked": profile.is_blocked if profile else False, "is_blocked": profile.is_blocked if profile else False,
"subscription_tier": (profile.subscription_tier or "free") if profile else "free", "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, "profile_id": profile.id if profile else None,
"document_count": doc_count, "document_count": doc_count,
"last_upload": last_upload, "last_upload": last_upload,
@@ -235,6 +262,9 @@ def upsert_user_profile(
profile.daily_upload_limit = body.daily_upload_limit profile.daily_upload_limit = body.daily_upload_limit
profile.notes = body.notes profile.notes = body.notes
profile.is_blocked = body.is_blocked profile.is_blocked = body.is_blocked
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: if body.subscription_tier is not None:
from app.utils.subscription import TIERS from app.utils.subscription import TIERS
+273
View File
@@ -0,0 +1,273 @@
"""REST API for subscription plan CRUD.
Endpoints:
GET /api/plans/ — list active plans (public)
GET /api/plans/admin — list all plans inc. inactive (admin only)
POST /api/plans/ — create plan (admin only)
GET /api/plans/{plan_id} — get single active plan (public)
PUT /api/plans/{plan_id} — update plan (admin only)
DELETE /api/plans/{plan_id} — delete plan (admin only)
POST /api/plans/seed — seed default plans (admin only)
POST /api/plans/reorder — set sort_order for multiple plans (admin only)
"""
import json
import logging
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import SubscriptionPlan
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/plans", tags=["plans"])
DbSession = Annotated[Session, Depends(get_db)]
# ---------------------------------------------------------------------------
# Auth helper (admin-only)
# ---------------------------------------------------------------------------
def _require_admin(request: Request) -> dict:
"""Ensure the caller is an admin. Raises 403 otherwise."""
user = request.session.get("user")
if not user or not user.get("is_admin"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return user
AdminUser = Annotated[dict, Depends(_require_admin)]
# ---------------------------------------------------------------------------
# Pydantic schemas
# ---------------------------------------------------------------------------
class PlanUpsert(BaseModel):
"""Body for creating or updating a subscription plan."""
name: str
tagline: str | None = None
price_monthly: float = 0.0
price_yearly: float = 0.0
trial_days: int = 0
lifetime_file_limit: int = 0
daily_upload_limit: int = 0
monthly_upload_limit: int = 0
max_storage_destinations: int = 0
max_ocr_pages_monthly: int = 0
max_file_size_mb: int = 0
max_mailboxes: int = 0
overage_percent: int = Field(default=20, ge=0, le=200)
allow_overage_billing: bool = False
overage_price_per_doc: float | None = None
overage_price_per_ocr_page: float | None = None
is_active: bool = True
is_highlighted: bool = False
badge_text: str | None = None
cta_text: str = "Get started"
sort_order: int = 0
features: list[str] = []
api_access: bool = False
class ReorderBody(BaseModel):
"""Body for reordering plans."""
order: list[str]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _plan_to_response(plan: SubscriptionPlan) -> dict[str, Any]:
features: list[str] = []
if plan.features:
try:
features = json.loads(plan.features)
except (json.JSONDecodeError, TypeError):
features = []
return {
"id": plan.id,
"plan_id": plan.plan_id,
"name": plan.name,
"tagline": plan.tagline,
"price_monthly": plan.price_monthly,
"price_yearly": plan.price_yearly,
"trial_days": plan.trial_days,
"lifetime_file_limit": plan.lifetime_file_limit,
"daily_upload_limit": plan.daily_upload_limit,
"monthly_upload_limit": plan.monthly_upload_limit,
"max_storage_destinations": plan.max_storage_destinations,
"max_ocr_pages_monthly": plan.max_ocr_pages_monthly,
"max_file_size_mb": plan.max_file_size_mb,
"max_mailboxes": plan.max_mailboxes,
"overage_percent": plan.overage_percent,
"allow_overage_billing": plan.allow_overage_billing,
"overage_price_per_doc": plan.overage_price_per_doc,
"overage_price_per_ocr_page": plan.overage_price_per_ocr_page,
"is_active": plan.is_active,
"is_highlighted": plan.is_highlighted,
"badge_text": plan.badge_text,
"cta_text": plan.cta_text,
"sort_order": plan.sort_order,
"features": features,
"api_access": plan.api_access,
"created_at": plan.created_at.isoformat() if plan.created_at else None,
"updated_at": plan.updated_at.isoformat() if plan.updated_at else None,
}
def _apply_body(plan: SubscriptionPlan, body: PlanUpsert) -> None:
"""Apply PlanUpsert fields onto a SubscriptionPlan ORM object."""
plan.name = body.name
plan.tagline = body.tagline
plan.price_monthly = body.price_monthly
plan.price_yearly = body.price_yearly
plan.trial_days = body.trial_days
plan.lifetime_file_limit = body.lifetime_file_limit
plan.daily_upload_limit = body.daily_upload_limit
plan.monthly_upload_limit = body.monthly_upload_limit
plan.max_storage_destinations = body.max_storage_destinations
plan.max_ocr_pages_monthly = body.max_ocr_pages_monthly
plan.max_file_size_mb = body.max_file_size_mb
plan.max_mailboxes = body.max_mailboxes
plan.overage_percent = body.overage_percent
plan.allow_overage_billing = body.allow_overage_billing
plan.overage_price_per_doc = body.overage_price_per_doc
plan.overage_price_per_ocr_page = body.overage_price_per_ocr_page
plan.is_active = body.is_active
plan.is_highlighted = body.is_highlighted
plan.badge_text = body.badge_text
plan.cta_text = body.cta_text
plan.sort_order = body.sort_order
plan.features = json.dumps(body.features)
plan.api_access = body.api_access
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("/", summary="List active plans (public)")
def list_active_plans(db: DbSession) -> dict[str, Any]:
"""Return all active plans in sort order. Public endpoint — no auth required."""
plans = (
db.query(SubscriptionPlan)
.filter(SubscriptionPlan.is_active.is_(True))
.order_by(SubscriptionPlan.sort_order)
.all()
)
return {"plans": [_plan_to_response(p) for p in plans]}
@router.get("/admin", summary="List all plans including inactive (admin only)")
def list_all_plans(db: DbSession, _admin: AdminUser) -> dict[str, Any]:
"""Return all plans (active and inactive) in sort order. Admin only."""
plans = db.query(SubscriptionPlan).order_by(SubscriptionPlan.sort_order).all()
return {"plans": [_plan_to_response(p) for p in plans]}
@router.post("/seed", summary="Seed default plans (admin only)", status_code=status.HTTP_200_OK)
def seed_plans(db: DbSession, _admin: AdminUser) -> dict[str, Any]:
"""Seed the subscription_plans table from TIER_DEFAULTS. No-op if plans already exist."""
from app.utils.subscription import seed_default_plans
inserted = seed_default_plans(db)
return {"inserted": inserted, "message": f"Seeded {inserted} default plan(s)."}
@router.post("/reorder", summary="Reorder plans (admin only)")
def reorder_plans(body: ReorderBody, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
"""Update sort_order for each plan_id in *body.order* (position = index in list)."""
updated = 0
for sort_order, plan_id in enumerate(body.order):
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.plan_id == plan_id).first()
if plan:
plan.sort_order = sort_order
updated += 1
try:
db.commit()
except Exception:
db.rollback()
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to reorder plans")
return {"updated": updated}
@router.post("/", summary="Create a new plan (admin only)", status_code=status.HTTP_201_CREATED)
def create_plan(plan_id: str, body: PlanUpsert, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
"""Create a new subscription plan with the given *plan_id* slug."""
existing = db.query(SubscriptionPlan).filter(SubscriptionPlan.plan_id == plan_id).first()
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Plan '{plan_id}' already exists.",
)
plan = SubscriptionPlan(plan_id=plan_id)
_apply_body(plan, body)
db.add(plan)
try:
db.commit()
db.refresh(plan)
except Exception:
db.rollback()
raise
logger.info("Admin created subscription plan '%s'", plan_id)
return _plan_to_response(plan)
@router.get("/{plan_id}", summary="Get a single active plan (public)")
def get_plan(plan_id: str, db: DbSession) -> dict[str, Any]:
"""Return a single active plan by plan_id. Public endpoint."""
plan = (
db.query(SubscriptionPlan)
.filter(
SubscriptionPlan.plan_id == plan_id,
SubscriptionPlan.is_active.is_(True),
)
.first()
)
if not plan:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Plan '{plan_id}' not found.")
return _plan_to_response(plan)
@router.put("/{plan_id}", summary="Update an existing plan (admin only)")
def update_plan(plan_id: str, body: PlanUpsert, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
"""Update an existing subscription plan. Admin only."""
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.plan_id == plan_id).first()
if not plan:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Plan '{plan_id}' not found.")
_apply_body(plan, body)
try:
db.commit()
db.refresh(plan)
except Exception:
db.rollback()
raise
logger.info("Admin updated subscription plan '%s'", plan_id)
return _plan_to_response(plan)
@router.delete("/{plan_id}", summary="Delete a plan (admin only)", status_code=status.HTTP_204_NO_CONTENT)
def delete_plan(plan_id: str, db: DbSession, _admin: AdminUser) -> None:
"""Delete a subscription plan. Admin only."""
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.plan_id == plan_id).first()
if not plan:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Plan '{plan_id}' not found.")
try:
db.delete(plan)
db.commit()
except Exception:
db.rollback()
raise
logger.info("Admin deleted subscription plan '%s'", plan_id)
+13
View File
@@ -153,6 +153,19 @@ class Settings(BaseSettings):
), ),
) )
subscription_overage_percent: int = Field(
default=20,
ge=0,
le=200,
description=(
"Soft-limit overage buffer in percent (0200). The announced monthly quota is "
"increased by this percentage for actual enforcement. E.g. 20 means a 150-doc/month "
"plan enforces at 180 docs (150 × 1.20). Set 0 to enforce exactly at the announced "
"limit. Per-plan overage_percent (set in Plan Designer) overrides this global default. "
"Default: 20."
),
)
# Authentik # Authentik
authentik_client_id: Optional[str] = None authentik_client_id: Optional[str] = None
authentik_client_secret: Optional[str] = None authentik_client_secret: Optional[str] = None
+13
View File
@@ -99,6 +99,19 @@ async def lifespan(app: FastAPI):
# Send startup notification # Send startup notification
notify_startup() 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 # Application is now running
yield yield
+54 -1
View File
@@ -1,6 +1,6 @@
# app/models.py # 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 from app.database import Base
@@ -204,5 +204,58 @@ class UserProfile(Base):
# NULL is treated as "free" by the subscription utility. # NULL is treated as "free" by the subscription utility.
subscription_tier = Column(String(50), nullable=True, default="free") 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()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
+162 -48
View File
@@ -3,9 +3,9 @@ Subscription tier definitions and enforcement utilities for DocuElevate SaaS.
Four tiers (prices ex-VAT; German customers +19 % MwSt): Four tiers (prices ex-VAT; German customers +19 % MwSt):
- free $0/mo — 50 lifetime docs, 150 lifetime OCR pages, 1 dest - 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 - starter $2.99/mo — 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 - professional $5.99/mo — 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 - business $7.99/mo — 300/mo, 1500 OCR pp/mo, 10 dests, unlimited mailboxes
Limits use 0 to represent "unlimited". Limits use 0 to represent "unlimited".
All paid tiers include a 30-day free trial (trial_days field). 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 import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Tier catalogue # Tier catalogue
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
TIERS: dict[str, dict[str, Any]] = { TIER_DEFAULTS: dict[str, dict[str, Any]] = {
"free": { "free": {
"id": "free", "id": "free",
"name": "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 # Display order for the pricing page
TIER_ORDER = ["free", "starter", "professional", "business"] TIER_ORDER = ["free", "starter", "professional", "business"]
@@ -168,19 +173,135 @@ TIER_ORDER = ["free", "starter", "professional", "business"]
DEFAULT_TIER = "free" 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 # Getters
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def get_tier(tier_id: str) -> dict[str, Any]: def get_tier(tier_id: str, db: Session | None = None) -> dict[str, Any]:
"""Return tier config dict; falls back to *free* for unknown ids.""" """Return plan config dict; DB-first when db is provided, falls back to TIER_DEFAULTS."""
return TIERS.get(tier_id, TIERS["free"]) 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]]: def get_all_tiers(db: Session | None = None) -> list[dict[str, Any]]:
"""Return tiers in display order.""" """Return plans in display order; DB-first when db is provided."""
return [TIERS[tid] for tid in TIER_ORDER] 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: def get_year_file_count(db: Session, owner_id: str, period_start: datetime) -> int:
"""Files processed since the start of the current subscription period. """Files processed since the start of the current annual subscription period."""
Used for yearly-subscription carry-over: compares cumulative usage against the
cumulative monthly budget since the annual period started.
"""
from app.models import FileRecord from app.models import FileRecord
return _scalar_count( 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: def _months_elapsed(period_start: datetime, now: datetime) -> int:
"""Calendar months elapsed since *period_start*, clamped to 112.""" """Calendar months elapsed since *period_start*, clamped to [1, 12]."""
elapsed = (now.year - period_start.year) * 12 + (now.month - period_start.month) + 1 elapsed = (now.year - period_start.year) * 12 + (now.month - period_start.month) + 1
return max(1, min(elapsed, 12)) 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: 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. """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 Skipped entirely when *owner_id* or *tier_id* is ``None`` (single-user mode).
is skipped entirely.
Enforcement model Enforcement model
----------------- -----------------
* **Announced limit** — the quota shown to users on the pricing page * **Announced limit** — the quota shown on the pricing page
(``monthly_upload_limit`` in TIERS). (``monthly_upload_limit`` in the plan).
* **Enforcement limit** — ``announced × settings.subscription_overage_factor`` * **Overage buffer** — each plan stores ``overage_percent`` (default 20).
(default 1.33). A 150-doc/month plan is therefore enforced at 200 docs, Enforcement = announced × (1 + overage_percent / 100). A 150-doc/month
giving users a soft buffer before they see an error. plan with 20 % buffer is enforced at 180 docs.
* **Overage flag** — if ``UserProfile.allow_overage`` is ``True`` the check * **Overage flag** — if ``UserProfile.allow_overage`` is ``True``, quota
is bypassed entirely. Usage is still tracked so future billing can charge checks are bypassed entirely so usage can be billed retroactively.
for overages. (Not yet exposed in the admin UI.) (Not yet exposed in the admin UI — baked in for future billing.)
* **Yearly carry-over** — yearly subscribers have cumulative quota: * **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. Unused quota from earlier months rolls forward automatically.
* **No daily cap** — ``daily_upload_limit`` is kept for display purposes
No daily cap is enforced — ``daily_upload_limit`` in TIERS is kept as only; it is never enforced.
informational data only.
""" """
if owner_id is None or tier_id is None: if owner_id is None or tier_id is None:
return return
tier = get_tier(tier_id) tier = get_tier(tier_id, db)
# Resolve overage factor from config # Per-plan overage_percent overrides global config default
from app.config import settings 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 from app.models import UserProfile
profile = db.query(UserProfile).filter(UserProfile.user_id == owner_id).first() 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 period_start: datetime | None = profile.subscription_period_start if profile else None
# 1. Lifetime file cap (free tier) — always enforced regardless of overage flag # 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: if lifetime_limit > 0:
enforcement_limit = int(lifetime_limit * overage_factor) enforcement_limit = int(lifetime_limit * overage_factor)
count = get_lifetime_file_count(db, owner_id) 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, 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: if allow_overage:
return return
monthly_limit = tier["monthly_upload_limit"] monthly_limit: int = tier["monthly_upload_limit"]
if monthly_limit > 0: if monthly_limit > 0:
if billing_cycle == "yearly" and period_start is not None: 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) now = datetime.now(timezone.utc)
months = _months_elapsed(period_start, now) months = _months_elapsed(period_start, now)
cumulative_budget = int(monthly_limit * months * overage_factor) 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: if cumulative_used >= cumulative_budget:
raise QuotaExceeded( raise QuotaExceeded(
f"Annual document quota for the {tier['name']} plan has been reached. " f"Annual document quota for the {tier['name']} plan has been reached. "
"Unused monthly quota carries forward — your limit will reset on your " "Unused monthly quota carries forward — your limit resets on your annual "
"annual renewal date, or you can upgrade your plan.", "renewal date, or you can upgrade your plan.",
limit_type="monthly", limit_type="monthly",
limit_value=monthly_limit, limit_value=monthly_limit,
current_value=cumulative_used, current_value=cumulative_used,
) )
else: else:
# Monthly billing: check current calendar month only
count = get_month_file_count(db, owner_id) count = get_month_file_count(db, owner_id)
enforcement_limit = int(monthly_limit * overage_factor) enforcement_limit = int(monthly_limit * overage_factor)
if count >= enforcement_limit: 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, limit_value=monthly_limit,
current_value=count, current_value=count,
) )
limit_value=monthly_limit,
current_value=count,
)
def get_user_tier_id(db: Session, owner_id: str | None) -> str: 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]: 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 file counts for *owner_id*, including carry-over data for yearly plans."""
return { 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), "lifetime": get_lifetime_file_count(db, owner_id),
"today": get_today_file_count(db, owner_id), "today": get_today_file_count(db, owner_id),
"month": get_month_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
+3 -1
View File
@@ -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.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.license_routes import router as license_router # Add the license router
from app.views.onedrive import router as onedrive_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.queue import router as queue_router
from app.views.search import router as search_router from app.views.search import router as search_router
from app.views.settings import router as settings_router from app.views.settings import router as settings_router
from app.views.status import router as status_router from app.views.status import router as status_router
from app.views.subscriptions import router as subscriptions_router from app.views.subscriptions import router as subscriptions_router # Pricing + subscription pages
from app.views.wizard import router as wizard_router from app.views.wizard import router as wizard_router
# Create a main router that includes all the view routers # Create a main router that includes all the view routers
@@ -37,3 +38,4 @@ router.include_router(filemanager_router)
router.include_router(search_router) router.include_router(search_router)
router.include_router(queue_router) router.include_router(queue_router)
router.include_router(subscriptions_router) # Pricing + subscription pages router.include_router(subscriptions_router) # Pricing + subscription pages
router.include_router(plans_router) # Admin Plan Designer
+18
View File
@@ -0,0 +1,18 @@
"""View route for the admin Plan Designer page."""
from fastapi import Request
from fastapi.responses import HTMLResponse
from fastapi.routing import APIRouter
from fastapi.templating import Jinja2Templates
from app.auth import require_login
router = APIRouter()
templates = Jinja2Templates(directory="frontend/templates")
@router.get("/admin/plans", response_class=HTMLResponse)
@require_login
async def plan_designer(request: Request) -> HTMLResponse:
"""Admin Plan Designer page."""
return templates.TemplateResponse("admin_plans.html", {"request": request})
+4 -4
View File
@@ -18,9 +18,9 @@ router = APIRouter()
@router.get("/pricing", include_in_schema=False) @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.""" """Public-facing pricing and plans page."""
tiers = get_all_tiers() tiers = get_all_tiers(db)
return templates.TemplateResponse( return templates.TemplateResponse(
"pricing.html", "pricing.html",
{ {
@@ -47,8 +47,8 @@ async def my_subscription_page(request: Request, db: Session = Depends(get_db)):
tier_id = "business" tier_id = "business"
usage = None usage = None
tier = get_tier(tier_id) tier = get_tier(tier_id, db)
all_tiers = get_all_tiers() all_tiers = get_all_tiers(db)
return templates.TemplateResponse( return templates.TemplateResponse(
"subscription.html", "subscription.html",
+74 -108
View File
@@ -1,151 +1,117 @@
# Subscription Tiers # Subscription Tiers
DocuElevate operates as a SaaS platform with four subscription tiers. When 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.
`MULTI_USER_ENABLED=True` each user is assigned a tier that controls how many
documents they can process and how many storage destinations they can use.
--- ## Default Plans
## Tier Overview | 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 |
| | Free | Starter | Professional | Business | > Prices ex-VAT. German customers add 19% MwSt.
|---|---|---|---|---|
| **Price / month** | $0 | $9 | $29 | $79 |
| **Price / year** | $0 | $90 | $290 | $790 |
| **Lifetime file limit** | 25 files (total, ever) | Unlimited | Unlimited | Unlimited |
| **Files per day** | Unlimited* | 10 | 50 | Unlimited |
| **Files per month** | Unlimited* | 100 | 500 | Unlimited |
| **Storage destinations** | 1 | 3 | 10 | Unlimited |
| **OCR pages / month** | 50 | 500 | 2 500 | Unlimited |
| **Max file size** | 10 MB | 50 MB | 200 MB | Unlimited |
| **API access** | ✗ | ✓ | ✓ | ✓ |
| **Email ingestion** | ✗ | ✓ | ✓ | ✓ |
| **Webhooks** | ✗ | ✗ | ✓ | ✓ |
| **Support** | Community | Email | Priority email | Dedicated |
\* Free tier is capped by the **lifetime** limit of 25 files; there is no All paid plans include a **30-day free trial**.
separate daily or monthly cap on top of that.
--- ## How Plans Are Stored
## Free Tier 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.
The Free tier is designed for exploration. It allows up to **25 documents 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.
processed in total across the lifetime of the account** — this is enforced
strictly; once 25 documents have been processed the upload endpoint returns
HTTP 402 and invites the user to upgrade.
There is no per-day or per-month cap: the user can use all 25 files in a ## Overage Buffer
single day if they wish.
--- ### Announced vs. Enforced Limit
## Paid Tiers DocuElevate uses a **soft-limit overage buffer** that is invisible to users:
### Starter — $9/month (or $90/year) - 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.
Good for individuals or small teams getting started with automated document ### Per-Plan vs. Global Buffer
processing. Provides a meaningful step up from the free tier without a high
price commitment.
- 10 documents/day, 100 documents/month Each plan has its own `overage_percent` field (set in the Plan Designer). There is also a global fallback: `settings.subscription_overage_percent` (default: 20, range: 0200), which applies when a plan does not have an explicit value.
- 3 storage destinations (e.g. Dropbox + Google Drive + Nextcloud)
- 500 OCR pages/month
- Email ingestion and API access included
### Professional — $29/month (or $290/year) *(Most Popular)* Set `subscription_overage_percent=0` in your `.env` to enforce exactly at the announced limit with no buffer.
Better for growing teams that need higher volume and more integration ## Yearly Billing & Carry-Over
flexibility.
- 50 documents/day, 500 documents/month When a user's `subscription_billing_cycle` is set to `yearly`:
- 10 storage destinations
- 2 500 OCR pages/month
- All processing steps, webhooks, and priority email support
### Business — $79/month (or $790/year) - Unused quota from earlier months **rolls forward automatically**.
- Enforcement = `monthly_limit × months_elapsed × overage_factor` (cumulative budget from the subscription start date).
- Example: A 50-doc/month Starter plan in month 3 of its annual period has a cumulative budget of 150 docs (plus overage buffer). If the user only used 20 docs in months 12, they can use 130 docs in month 3.
- The `subscription_period_start` field on `UserProfile` tracks the start of the annual period.
Best for organisations that need truly unlimited throughput with dedicated ## No Daily Cap
support.
- Unlimited documents and storage destinations `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).
- Unlimited OCR pages
- Unlimited file size
- Custom integrations and dedicated support
> **Contact Sales** for the Business tier — email ## allow_overage Flag
> `sales@docuelevate.io` with subject "Business Plan Enquiry".
--- 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.
## Limit Enforcement ## Plan Designer
Limits are enforced in real-time at the upload endpoint Navigate to `/admin/plans` (admin only) to:
(`POST /api/ui-upload`). When a user exceeds any quota:
1. The uploaded file is discarded. 1. **View** all plans (active and inactive) with key stats.
2. The endpoint returns **HTTP 402 Payment Required** with a human-readable 2. **Create** a new plan with a custom `plan_id` slug.
`detail` message explaining which limit was hit and how to upgrade. 3. **Edit** any plan's pricing, limits, overage buffer, features, and display settings.
3. The upload UI displays the error message to the user. 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).
Quota checks run in order: ### Overage Designer
1. Lifetime file limit (free tier only) The Plan Designer includes an overage slider (0100%). The live preview shows:
2. Daily file limit
3. Monthly file limit
--- > "Announce **X** docs, enforce at **Y** docs"
## Admin Management Overage billing and per-doc overage pricing are planned future features (currently disabled in the UI).
Administrators can view and change each user's subscription tier from the ## API Endpoints
**Admin → Users** page (`/admin/users`).
1. Click **Edit** next to any user. All plan endpoints are under `/api/plans/`.
2. Change the **Subscription Plan** dropdown.
3. Click **Save Changes**.
The new limits take effect immediately on the user's next upload attempt. | 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", ...]}` |
### API ### Example: List Active Plans
Admins can also manage tiers via the REST API:
```bash ```bash
# Update a user's subscription tier curl http://localhost:8000/api/plans/
curl -X PUT /api/admin/users/user@example.com \
-H 'Content-Type: application/json' \
-d '{"subscription_tier": "professional", "is_blocked": false}'
``` ```
--- ### Example: Update a Plan's Monthly Limit
## Platform Statistics ```bash
curl -X PUT http://localhost:8000/api/plans/starter \
Admins can view platform-wide statistics via: -H "Content-Type: application/json" \
-d '{
- **Dashboard** (`/`) — shows total files, today, this month, unique users "name": "Starter",
when `MULTI_USER_ENABLED=True`. "monthly_upload_limit": 75,
- **API** — `GET /api/subscriptions/platform` returns aggregate file counts "overage_percent": 15,
and per-tier user distribution. ...
}'
--- ```
## Configuration ## Configuration
Subscription tiers are defined in `app/utils/subscription.py` in the `TIERS` | Variable | Default | Description |
dictionary. Pricing, limits, and feature lists are all set there. |----------|---------|-------------|
| `SUBSCRIPTION_OVERAGE_PERCENT` | `20` | Global overage buffer (0200). Per-plan setting overrides this. |
Single-user mode (`MULTI_USER_ENABLED=False`) bypasses all quota checks See `docs/ConfigurationGuide.md` for all available settings.
entirely — the instance behaves as if every request is on the Business tier.
---
## Pricing Page
The public pricing page is available at `/pricing` and requires no
authentication. It shows the interactive tier comparison table with an
annual/monthly toggle and an FAQ section.
Individual users can view their own subscription status, usage progress bars,
and upgrade options at `/subscription` (requires login).
+608
View File
@@ -0,0 +1,608 @@
{% 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></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 %}
+39 -4
View File
@@ -335,15 +335,46 @@
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" 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="free">Free — 25 lifetime files</option>
<option value="starter">Starter — $2.99/mo (5/day, 50/mo, 1 mailbox)</option> <option value="starter">Starter — $2.99/mo (50/mo, 1 mailbox)</option>
<option value="professional">Professional — $5.99/mo (15/day, 150/mo, 3 mailboxes)</option> <option value="professional">Professional — $5.99/mo (150/mo, 3 mailboxes)</option>
<option value="business">Business — $7.99/mo (30/day, 300/mo, unlimited mailboxes)</option> <option value="business">Business — $7.99/mo (300/mo, unlimited mailboxes)</option>
</select> </select>
<p class="text-xs text-gray-400 mt-1"> <p class="text-xs text-gray-400 mt-1">
Sets the quota limits for this user. Limits are enforced on upload. Sets the quota limits for this user. Limits are enforced on upload.
</p> </p>
</div> </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> </div>
<!-- Footer --> <!-- Footer -->
@@ -439,6 +470,8 @@ function adminUsersApp() {
notes: '', notes: '',
is_blocked: false, is_blocked: false,
subscription_tier: 'free', subscription_tier: 'free',
subscription_billing_cycle: 'monthly',
subscription_period_start: null,
}, },
// Delete modal // Delete modal
@@ -491,7 +524,7 @@ function adminUsersApp() {
openCreateModal() { openCreateModal() {
this.isCreate = true; this.isCreate = true;
this.modalTitle = 'Add User Profile'; this.modalTitle = 'Add User Profile';
this.form = { user_id: '', display_name: '', daily_upload_limit: null, notes: '', is_blocked: false, subscription_tier: 'free' }; 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; this.modalOpen = true;
}, },
@@ -506,6 +539,8 @@ function adminUsersApp() {
notes: user.notes || '', notes: user.notes || '',
is_blocked: !!user.is_blocked, is_blocked: !!user.is_blocked,
subscription_tier: user.subscription_tier || 'free', 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; this.modalOpen = true;
}, },
+6
View File
@@ -99,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"> <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 <i class="fas fa-users w-4 mr-2 text-blue-500" aria-hidden="true"></i> Users
</a> </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"> <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 <i class="fas fa-key w-4 mr-2 text-yellow-500" aria-hidden="true"></i> Credentials
</a> </a>
@@ -186,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"> <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 <i class="fas fa-users mr-2 text-blue-400" aria-hidden="true"></i> Users
</a> </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"> <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 <i class="fas fa-key mr-2 text-yellow-400" aria-hidden="true"></i> Credentials
</a> </a>
-13
View File
@@ -170,19 +170,6 @@
{% endfor %} {% endfor %}
</tr> </tr>
<tr class="hover:bg-indigo-50/30 transition">
<td class="px-6 py-3 text-sm text-gray-700">Files per day</td>
{% for tier in tiers %}
<td class="px-4 py-3 text-center text-sm">
{% if tier.daily_upload_limit == 0 %}
<span class="font-semibold text-green-600">Unlimited</span>
{% else %}
<span class="font-medium text-gray-800">{{ tier.daily_upload_limit }}</span>
{% endif %}
</td>
{% endfor %}
</tr>
<tr class="hover:bg-indigo-50/30 transition"> <tr class="hover:bg-indigo-50/30 transition">
<td class="px-6 py-3 text-sm text-gray-700">Files per month</td> <td class="px-6 py-3 text-sm text-gray-700">Files per month</td>
{% for tier in tiers %} {% for tier in tiers %}
@@ -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")
+239 -56
View File
@@ -1,27 +1,24 @@
"""Unit tests for the subscription tier utility module.""" """Unit tests for the subscription tier utility module."""
import pytest from datetime import datetime, timezone
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from datetime import datetime, timezone, date
import pytest
from app.utils.subscription import ( from app.utils.subscription import (
TIERS,
TIER_ORDER,
DEFAULT_TIER, DEFAULT_TIER,
get_tier, TIER_DEFAULTS,
TIER_ORDER,
TIERS,
QuotaExceeded,
_months_elapsed,
check_upload_allowed,
get_all_tiers, get_all_tiers,
get_tier,
get_user_tier_id, get_user_tier_id,
get_user_usage, get_user_usage,
check_upload_allowed,
QuotaExceeded,
get_lifetime_file_count,
get_today_file_count,
get_month_file_count,
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Basic catalogue tests # Basic catalogue tests
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -69,12 +66,21 @@ def test_get_all_tiers_returns_four():
@pytest.mark.unit @pytest.mark.unit
def test_all_tiers_have_required_fields(): def test_all_tiers_have_required_fields():
required = [ required = [
"id", "name", "tagline", "price_monthly", "price_yearly", "id",
"name",
"tagline",
"price_monthly",
"price_yearly",
"trial_days", "trial_days",
"lifetime_file_limit", "daily_upload_limit", "monthly_upload_limit", "lifetime_file_limit",
"max_storage_destinations", "max_ocr_pages_monthly", "max_file_size_mb", "daily_upload_limit",
"monthly_upload_limit",
"max_storage_destinations",
"max_ocr_pages_monthly",
"max_file_size_mb",
"max_mailboxes", "max_mailboxes",
"features", "cta", "features",
"cta",
] ]
for tid, tier in TIERS.items(): for tid, tier in TIERS.items():
for field in required: for field in required:
@@ -103,18 +109,21 @@ def test_free_tier_has_no_mailboxes():
def test_business_tier_has_highest_limits(): def test_business_tier_has_highest_limits():
"""Business tier must have the highest limits of all paid tiers.""" """Business tier must have the highest limits of all paid tiers."""
t = TIERS["business"] t = TIERS["business"]
# lifetime, daily, monthly: no hard cap (0 = unlimited) for lifetime; daily/monthly capped # lifetime: no hard cap (0 = unlimited)
assert t["lifetime_file_limit"] == 0 assert t["lifetime_file_limit"] == 0
assert t["daily_upload_limit"] == 30 # no daily cap (0 = unlimited)
assert t["daily_upload_limit"] == 0
assert t["monthly_upload_limit"] == 300 assert t["monthly_upload_limit"] == 300
assert t["max_ocr_pages_monthly"] == 1500 assert t["max_ocr_pages_monthly"] == 1500
# unlimited mailboxes # unlimited mailboxes (0 = unlimited)
assert t["max_mailboxes"] == 0 assert t["max_mailboxes"] == 0
# unlimited file size (0 = unlimited)
assert t["max_file_size_mb"] == 0
@pytest.mark.unit @pytest.mark.unit
def test_mailbox_limits_increase_by_tier(): def test_mailbox_limits_increase_by_tier():
"""Mailbox limits must increase across tiers: free=0, starter=1, professional=3, business=0().""" """Mailbox limits must increase across tiers: free=0, starter=1, professional=3, business=0(inf)."""
assert TIERS["free"]["max_mailboxes"] == 0 assert TIERS["free"]["max_mailboxes"] == 0
assert TIERS["starter"]["max_mailboxes"] == 1 assert TIERS["starter"]["max_mailboxes"] == 1
assert TIERS["professional"]["max_mailboxes"] == 3 assert TIERS["professional"]["max_mailboxes"] == 3
@@ -197,10 +206,16 @@ def test_check_upload_skipped_without_tier():
@pytest.mark.unit @pytest.mark.unit
def test_check_upload_raises_when_lifetime_exceeded(): def test_check_upload_raises_when_lifetime_exceeded():
"""Free tier: should raise QuotaExceeded when lifetime limit (50) is hit.""" """Free tier: raise QuotaExceeded at lifetime limit (50) with 0% buffer (exact enforcement)."""
db = MagicMock() 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.get_lifetime_file_count", return_value=50): 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: with pytest.raises(QuotaExceeded) as exc_info:
check_upload_allowed(db, "user@example.com", "free") check_upload_allowed(db, "user@example.com", "free")
@@ -212,31 +227,34 @@ def test_check_upload_raises_when_lifetime_exceeded():
@pytest.mark.unit @pytest.mark.unit
def test_check_upload_passes_below_lifetime_limit(): def test_check_upload_passes_below_lifetime_limit():
db = MagicMock() db = MagicMock()
with patch("app.utils.subscription.get_lifetime_file_count", return_value=10): 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 check_upload_allowed(db, "user@example.com", "free") # must not raise
@pytest.mark.unit
def test_check_upload_raises_when_daily_exceeded():
"""Starter tier: should raise QuotaExceeded when daily limit (5) is hit."""
db = MagicMock()
with patch("app.utils.subscription.get_lifetime_file_count", return_value=0), \
patch("app.utils.subscription.get_today_file_count", return_value=5):
with pytest.raises(QuotaExceeded) as exc_info:
check_upload_allowed(db, "user@example.com", "starter")
assert exc_info.value.limit_type == "daily"
@pytest.mark.unit @pytest.mark.unit
def test_check_upload_raises_when_monthly_exceeded(): def test_check_upload_raises_when_monthly_exceeded():
"""Starter tier: should raise QuotaExceeded when monthly limit (50) is hit.""" """Starter tier: raise QuotaExceeded when monthly limit (50) is hit (0% buffer)."""
db = MagicMock() db = MagicMock()
with patch("app.utils.subscription.get_lifetime_file_count", return_value=0), \ # UserProfile mock: no overage, monthly billing, no period_start
patch("app.utils.subscription.get_today_file_count", return_value=0), \ profile_mock = MagicMock()
patch("app.utils.subscription.get_month_file_count", return_value=50): 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: with pytest.raises(QuotaExceeded) as exc_info:
check_upload_allowed(db, "user@example.com", "starter") check_upload_allowed(db, "user@example.com", "starter")
@@ -245,27 +263,154 @@ def test_check_upload_raises_when_monthly_exceeded():
@pytest.mark.unit @pytest.mark.unit
def test_check_upload_business_tier_within_limits(): def test_check_upload_business_tier_within_limits():
"""Business tier: upload is allowed as long as counts are below the capped limits.""" """Business tier: upload is allowed when count is below the monthly limit."""
db = MagicMock() db = MagicMock()
# Use counts well below Business limits (30/day, 300/mo) profile_mock = MagicMock()
with patch("app.utils.subscription.get_lifetime_file_count", return_value=0), \ profile_mock.allow_overage = False
patch("app.utils.subscription.get_today_file_count", return_value=10), \ profile_mock.subscription_billing_cycle = "monthly"
patch("app.utils.subscription.get_month_file_count", return_value=100): 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 check_upload_allowed(db, "user@example.com", "business") # must not raise
# ---------------------------------------------------------------------------
# Overage buffer tests
# ---------------------------------------------------------------------------
@pytest.mark.unit @pytest.mark.unit
def test_check_upload_business_tier_raises_when_daily_exceeded(): def test_overage_percent_allows_buffer():
"""Business tier: should raise QuotaExceeded when daily limit (30) is hit.""" """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 = MagicMock()
db.query.return_value.filter.return_value.first.side_effect = [None, profile_mock]
with patch("app.utils.subscription.get_lifetime_file_count", return_value=0), \ with (
patch("app.utils.subscription.get_today_file_count", return_value=30): 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: with pytest.raises(QuotaExceeded) as exc_info:
check_upload_allowed(db, "user@example.com", "business") check_upload_allowed(db2, "user@example.com", "starter")
assert exc_info.value.limit_type == "monthly"
assert exc_info.value.limit_value == 50
assert exc_info.value.limit_type == "daily"
assert exc_info.value.limit_value == 30 @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
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -276,14 +421,52 @@ def test_check_upload_business_tier_raises_when_daily_exceeded():
@pytest.mark.unit @pytest.mark.unit
def test_get_user_usage_returns_dict_with_correct_keys(): def test_get_user_usage_returns_dict_with_correct_keys():
db = MagicMock() db = MagicMock()
with patch("app.utils.subscription.get_lifetime_file_count", return_value=10), \ # No profile -> monthly billing, no period_start
patch("app.utils.subscription.get_today_file_count", return_value=2), \ db.query.return_value.filter.return_value.first.return_value = None
patch("app.utils.subscription.get_month_file_count", return_value=8):
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") result = get_user_usage(db, "user@example.com")
assert result == {"lifetime": 10, "today": 2, "month": 8} 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) # API: /api/subscriptions/tiers (integration-style, mocked app)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------