feat(subscriptions): database-backed plan designer with admin CRUD and overage buffer
- Add SubscriptionPlan model and subscription_plans table (migration 015) - Add billing cycle/period/allow_overage fields to UserProfile (migration 016) - Add subscription_overage_percent config field (replaces overage_factor) - Rewrite check_upload_allowed: use overage_percent, yearly carry-over, no daily cap - Add seed_default_plans(), _plan_to_dict(), get_year_file_count(), _months_elapsed() - Update get_tier/get_all_tiers to be DB-first with TIER_DEFAULTS fallback - Add TIER_DEFAULTS alias (TIERS kept for backward compat) - New /api/plans/ CRUD endpoints (admin-only except list/get) - New /admin/plans Plan Designer page with Alpine.js UI - Add Plan Designer link to admin navigation in base.html - Remove 'Files per day' row from pricing comparison table - Add billing cycle + period start to admin users edit modal - Seed default plans on startup in lifespan handler - Rewrite docs/SubscriptionTiers.md with full plan/overage/API docs - Fix all tests in test_subscription.py (remove daily cap tests, add overage/carry-over tests) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -17,6 +17,7 @@ from app.api.google_drive import router as google_drive_router
|
||||
from app.api.logs import router as logs_router
|
||||
from app.api.onedrive import router as onedrive_router
|
||||
from app.api.openai import router as openai_router
|
||||
from app.api.plans import router as plans_router
|
||||
from app.api.process import router as process_router
|
||||
from app.api.queue import router as queue_router
|
||||
from app.api.saved_searches import router as saved_searches_router
|
||||
@@ -58,3 +59,4 @@ router.include_router(duplicates_router)
|
||||
router.include_router(webhooks_router)
|
||||
router.include_router(database_router)
|
||||
router.include_router(subscriptions_router)
|
||||
router.include_router(plans_router)
|
||||
|
||||
@@ -5,6 +5,7 @@ administrators can inspect, configure, and manage users in multi-user mode.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
@@ -55,6 +56,9 @@ class UserProfileUpsert(BaseModel):
|
||||
default="free",
|
||||
description="Subscription tier: free | starter | professional | business",
|
||||
)
|
||||
subscription_billing_cycle: str = Field(default="monthly", pattern="^(monthly|yearly)$")
|
||||
subscription_period_start: datetime | None = None
|
||||
allow_overage: bool = False
|
||||
|
||||
|
||||
class UserProfileResponse(BaseModel):
|
||||
@@ -67,6 +71,9 @@ class UserProfileResponse(BaseModel):
|
||||
notes: str | None
|
||||
is_blocked: bool
|
||||
subscription_tier: str | None
|
||||
subscription_billing_cycle: str
|
||||
subscription_period_start: str | None
|
||||
allow_overage: bool
|
||||
created_at: str | None
|
||||
updated_at: str | None
|
||||
|
||||
@@ -82,6 +89,9 @@ class UserSummary(BaseModel):
|
||||
notes: str | None
|
||||
is_blocked: bool
|
||||
subscription_tier: str | None
|
||||
subscription_billing_cycle: str | None
|
||||
subscription_period_start: str | None
|
||||
allow_overage: bool
|
||||
profile_id: int | None
|
||||
document_count: int
|
||||
last_upload: str | None
|
||||
@@ -106,6 +116,11 @@ def _profile_to_dict(profile: UserProfile) -> dict[str, Any]:
|
||||
"notes": profile.notes,
|
||||
"is_blocked": profile.is_blocked,
|
||||
"subscription_tier": profile.subscription_tier or "free",
|
||||
"subscription_billing_cycle": profile.subscription_billing_cycle or "monthly",
|
||||
"subscription_period_start": profile.subscription_period_start.isoformat()
|
||||
if profile.subscription_period_start
|
||||
else None,
|
||||
"allow_overage": bool(profile.allow_overage),
|
||||
"created_at": profile.created_at.isoformat() if profile.created_at else None,
|
||||
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
|
||||
}
|
||||
@@ -172,6 +187,13 @@ def list_users(
|
||||
"notes": profile.notes if profile else None,
|
||||
"is_blocked": profile.is_blocked if profile else False,
|
||||
"subscription_tier": (profile.subscription_tier or "free") if profile else "free",
|
||||
"subscription_billing_cycle": (profile.subscription_billing_cycle or "monthly")
|
||||
if profile
|
||||
else "monthly",
|
||||
"subscription_period_start": profile.subscription_period_start.isoformat()
|
||||
if (profile and profile.subscription_period_start)
|
||||
else None,
|
||||
"allow_overage": bool(profile.allow_overage) if profile else False,
|
||||
"profile_id": profile.id if profile else None,
|
||||
"document_count": doc_row.doc_count if doc_row else 0,
|
||||
"last_upload": doc_row.last_upload.isoformat() if (doc_row and doc_row.last_upload) else None,
|
||||
@@ -208,6 +230,11 @@ def get_user(user_id: str, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
|
||||
"notes": profile.notes if profile else None,
|
||||
"is_blocked": profile.is_blocked if profile else False,
|
||||
"subscription_tier": (profile.subscription_tier or "free") if profile else "free",
|
||||
"subscription_billing_cycle": (profile.subscription_billing_cycle or "monthly") if profile else "monthly",
|
||||
"subscription_period_start": profile.subscription_period_start.isoformat()
|
||||
if (profile and profile.subscription_period_start)
|
||||
else None,
|
||||
"allow_overage": bool(profile.allow_overage) if profile else False,
|
||||
"profile_id": profile.id if profile else None,
|
||||
"document_count": doc_count,
|
||||
"last_upload": last_upload,
|
||||
@@ -235,6 +262,9 @@ def upsert_user_profile(
|
||||
profile.daily_upload_limit = body.daily_upload_limit
|
||||
profile.notes = body.notes
|
||||
profile.is_blocked = body.is_blocked
|
||||
profile.subscription_billing_cycle = body.subscription_billing_cycle
|
||||
profile.subscription_period_start = body.subscription_period_start
|
||||
profile.allow_overage = body.allow_overage
|
||||
if body.subscription_tier is not None:
|
||||
from app.utils.subscription import TIERS
|
||||
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
"""REST API for subscription plan CRUD.
|
||||
|
||||
Endpoints:
|
||||
GET /api/plans/ — list active plans (public)
|
||||
GET /api/plans/admin — list all plans inc. inactive (admin only)
|
||||
POST /api/plans/ — create plan (admin only)
|
||||
GET /api/plans/{plan_id} — get single active plan (public)
|
||||
PUT /api/plans/{plan_id} — update plan (admin only)
|
||||
DELETE /api/plans/{plan_id} — delete plan (admin only)
|
||||
POST /api/plans/seed — seed default plans (admin only)
|
||||
POST /api/plans/reorder — set sort_order for multiple plans (admin only)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import SubscriptionPlan
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/plans", tags=["plans"])
|
||||
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth helper (admin-only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _require_admin(request: Request) -> dict:
|
||||
"""Ensure the caller is an admin. Raises 403 otherwise."""
|
||||
user = request.session.get("user")
|
||||
if not user or not user.get("is_admin"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||||
return user
|
||||
|
||||
|
||||
AdminUser = Annotated[dict, Depends(_require_admin)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pydantic schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PlanUpsert(BaseModel):
|
||||
"""Body for creating or updating a subscription plan."""
|
||||
|
||||
name: str
|
||||
tagline: str | None = None
|
||||
price_monthly: float = 0.0
|
||||
price_yearly: float = 0.0
|
||||
trial_days: int = 0
|
||||
lifetime_file_limit: int = 0
|
||||
daily_upload_limit: int = 0
|
||||
monthly_upload_limit: int = 0
|
||||
max_storage_destinations: int = 0
|
||||
max_ocr_pages_monthly: int = 0
|
||||
max_file_size_mb: int = 0
|
||||
max_mailboxes: int = 0
|
||||
overage_percent: int = Field(default=20, ge=0, le=200)
|
||||
allow_overage_billing: bool = False
|
||||
overage_price_per_doc: float | None = None
|
||||
overage_price_per_ocr_page: float | None = None
|
||||
is_active: bool = True
|
||||
is_highlighted: bool = False
|
||||
badge_text: str | None = None
|
||||
cta_text: str = "Get started"
|
||||
sort_order: int = 0
|
||||
features: list[str] = []
|
||||
api_access: bool = False
|
||||
|
||||
|
||||
class ReorderBody(BaseModel):
|
||||
"""Body for reordering plans."""
|
||||
|
||||
order: list[str]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _plan_to_response(plan: SubscriptionPlan) -> dict[str, Any]:
|
||||
features: list[str] = []
|
||||
if plan.features:
|
||||
try:
|
||||
features = json.loads(plan.features)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
features = []
|
||||
return {
|
||||
"id": plan.id,
|
||||
"plan_id": plan.plan_id,
|
||||
"name": plan.name,
|
||||
"tagline": plan.tagline,
|
||||
"price_monthly": plan.price_monthly,
|
||||
"price_yearly": plan.price_yearly,
|
||||
"trial_days": plan.trial_days,
|
||||
"lifetime_file_limit": plan.lifetime_file_limit,
|
||||
"daily_upload_limit": plan.daily_upload_limit,
|
||||
"monthly_upload_limit": plan.monthly_upload_limit,
|
||||
"max_storage_destinations": plan.max_storage_destinations,
|
||||
"max_ocr_pages_monthly": plan.max_ocr_pages_monthly,
|
||||
"max_file_size_mb": plan.max_file_size_mb,
|
||||
"max_mailboxes": plan.max_mailboxes,
|
||||
"overage_percent": plan.overage_percent,
|
||||
"allow_overage_billing": plan.allow_overage_billing,
|
||||
"overage_price_per_doc": plan.overage_price_per_doc,
|
||||
"overage_price_per_ocr_page": plan.overage_price_per_ocr_page,
|
||||
"is_active": plan.is_active,
|
||||
"is_highlighted": plan.is_highlighted,
|
||||
"badge_text": plan.badge_text,
|
||||
"cta_text": plan.cta_text,
|
||||
"sort_order": plan.sort_order,
|
||||
"features": features,
|
||||
"api_access": plan.api_access,
|
||||
"created_at": plan.created_at.isoformat() if plan.created_at else None,
|
||||
"updated_at": plan.updated_at.isoformat() if plan.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _apply_body(plan: SubscriptionPlan, body: PlanUpsert) -> None:
|
||||
"""Apply PlanUpsert fields onto a SubscriptionPlan ORM object."""
|
||||
plan.name = body.name
|
||||
plan.tagline = body.tagline
|
||||
plan.price_monthly = body.price_monthly
|
||||
plan.price_yearly = body.price_yearly
|
||||
plan.trial_days = body.trial_days
|
||||
plan.lifetime_file_limit = body.lifetime_file_limit
|
||||
plan.daily_upload_limit = body.daily_upload_limit
|
||||
plan.monthly_upload_limit = body.monthly_upload_limit
|
||||
plan.max_storage_destinations = body.max_storage_destinations
|
||||
plan.max_ocr_pages_monthly = body.max_ocr_pages_monthly
|
||||
plan.max_file_size_mb = body.max_file_size_mb
|
||||
plan.max_mailboxes = body.max_mailboxes
|
||||
plan.overage_percent = body.overage_percent
|
||||
plan.allow_overage_billing = body.allow_overage_billing
|
||||
plan.overage_price_per_doc = body.overage_price_per_doc
|
||||
plan.overage_price_per_ocr_page = body.overage_price_per_ocr_page
|
||||
plan.is_active = body.is_active
|
||||
plan.is_highlighted = body.is_highlighted
|
||||
plan.badge_text = body.badge_text
|
||||
plan.cta_text = body.cta_text
|
||||
plan.sort_order = body.sort_order
|
||||
plan.features = json.dumps(body.features)
|
||||
plan.api_access = body.api_access
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/", summary="List active plans (public)")
|
||||
def list_active_plans(db: DbSession) -> dict[str, Any]:
|
||||
"""Return all active plans in sort order. Public endpoint — no auth required."""
|
||||
plans = (
|
||||
db.query(SubscriptionPlan)
|
||||
.filter(SubscriptionPlan.is_active.is_(True))
|
||||
.order_by(SubscriptionPlan.sort_order)
|
||||
.all()
|
||||
)
|
||||
return {"plans": [_plan_to_response(p) for p in plans]}
|
||||
|
||||
|
||||
@router.get("/admin", summary="List all plans including inactive (admin only)")
|
||||
def list_all_plans(db: DbSession, _admin: AdminUser) -> dict[str, Any]:
|
||||
"""Return all plans (active and inactive) in sort order. Admin only."""
|
||||
plans = db.query(SubscriptionPlan).order_by(SubscriptionPlan.sort_order).all()
|
||||
return {"plans": [_plan_to_response(p) for p in plans]}
|
||||
|
||||
|
||||
@router.post("/seed", summary="Seed default plans (admin only)", status_code=status.HTTP_200_OK)
|
||||
def seed_plans(db: DbSession, _admin: AdminUser) -> dict[str, Any]:
|
||||
"""Seed the subscription_plans table from TIER_DEFAULTS. No-op if plans already exist."""
|
||||
from app.utils.subscription import seed_default_plans
|
||||
|
||||
inserted = seed_default_plans(db)
|
||||
return {"inserted": inserted, "message": f"Seeded {inserted} default plan(s)."}
|
||||
|
||||
|
||||
@router.post("/reorder", summary="Reorder plans (admin only)")
|
||||
def reorder_plans(body: ReorderBody, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
|
||||
"""Update sort_order for each plan_id in *body.order* (position = index in list)."""
|
||||
updated = 0
|
||||
for sort_order, plan_id in enumerate(body.order):
|
||||
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.plan_id == plan_id).first()
|
||||
if plan:
|
||||
plan.sort_order = sort_order
|
||||
updated += 1
|
||||
try:
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to reorder plans")
|
||||
return {"updated": updated}
|
||||
|
||||
|
||||
@router.post("/", summary="Create a new plan (admin only)", status_code=status.HTTP_201_CREATED)
|
||||
def create_plan(plan_id: str, body: PlanUpsert, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
|
||||
"""Create a new subscription plan with the given *plan_id* slug."""
|
||||
existing = db.query(SubscriptionPlan).filter(SubscriptionPlan.plan_id == plan_id).first()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Plan '{plan_id}' already exists.",
|
||||
)
|
||||
plan = SubscriptionPlan(plan_id=plan_id)
|
||||
_apply_body(plan, body)
|
||||
db.add(plan)
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(plan)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
logger.info("Admin created subscription plan '%s'", plan_id)
|
||||
return _plan_to_response(plan)
|
||||
|
||||
|
||||
@router.get("/{plan_id}", summary="Get a single active plan (public)")
|
||||
def get_plan(plan_id: str, db: DbSession) -> dict[str, Any]:
|
||||
"""Return a single active plan by plan_id. Public endpoint."""
|
||||
plan = (
|
||||
db.query(SubscriptionPlan)
|
||||
.filter(
|
||||
SubscriptionPlan.plan_id == plan_id,
|
||||
SubscriptionPlan.is_active.is_(True),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not plan:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Plan '{plan_id}' not found.")
|
||||
return _plan_to_response(plan)
|
||||
|
||||
|
||||
@router.put("/{plan_id}", summary="Update an existing plan (admin only)")
|
||||
def update_plan(plan_id: str, body: PlanUpsert, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
|
||||
"""Update an existing subscription plan. Admin only."""
|
||||
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.plan_id == plan_id).first()
|
||||
if not plan:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Plan '{plan_id}' not found.")
|
||||
_apply_body(plan, body)
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(plan)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
logger.info("Admin updated subscription plan '%s'", plan_id)
|
||||
return _plan_to_response(plan)
|
||||
|
||||
|
||||
@router.delete("/{plan_id}", summary="Delete a plan (admin only)", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_plan(plan_id: str, db: DbSession, _admin: AdminUser) -> None:
|
||||
"""Delete a subscription plan. Admin only."""
|
||||
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.plan_id == plan_id).first()
|
||||
if not plan:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Plan '{plan_id}' not found.")
|
||||
try:
|
||||
db.delete(plan)
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
logger.info("Admin deleted subscription plan '%s'", plan_id)
|
||||
@@ -153,6 +153,19 @@ class Settings(BaseSettings):
|
||||
),
|
||||
)
|
||||
|
||||
subscription_overage_percent: int = Field(
|
||||
default=20,
|
||||
ge=0,
|
||||
le=200,
|
||||
description=(
|
||||
"Soft-limit overage buffer in percent (0–200). The announced monthly quota is "
|
||||
"increased by this percentage for actual enforcement. E.g. 20 means a 150-doc/month "
|
||||
"plan enforces at 180 docs (150 × 1.20). Set 0 to enforce exactly at the announced "
|
||||
"limit. Per-plan overage_percent (set in Plan Designer) overrides this global default. "
|
||||
"Default: 20."
|
||||
),
|
||||
)
|
||||
|
||||
# Authentik
|
||||
authentik_client_id: Optional[str] = None
|
||||
authentik_client_secret: Optional[str] = None
|
||||
|
||||
+13
@@ -99,6 +99,19 @@ async def lifespan(app: FastAPI):
|
||||
# Send startup notification
|
||||
notify_startup()
|
||||
|
||||
# Seed default subscription plans if none exist
|
||||
try:
|
||||
from app.database import SessionLocal as _SessionLocal
|
||||
from app.utils.subscription import seed_default_plans as _seed_plans
|
||||
|
||||
_db_seed = _SessionLocal()
|
||||
try:
|
||||
_seed_plans(_db_seed)
|
||||
finally:
|
||||
_db_seed.close()
|
||||
except Exception:
|
||||
logging.debug("Subscription plan seeding skipped — DB may not be ready yet") # noqa: S110
|
||||
|
||||
# Application is now running
|
||||
yield
|
||||
|
||||
|
||||
+54
-1
@@ -1,6 +1,6 @@
|
||||
# app/models.py
|
||||
|
||||
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy import Boolean, Column, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func
|
||||
|
||||
from app.database import Base
|
||||
|
||||
@@ -204,5 +204,58 @@ class UserProfile(Base):
|
||||
# NULL is treated as "free" by the subscription utility.
|
||||
subscription_tier = Column(String(50), nullable=True, default="free")
|
||||
|
||||
# Billing cycle and overage settings (added in migration 016)
|
||||
subscription_billing_cycle = Column(String(10), nullable=False, default="monthly", server_default="monthly")
|
||||
subscription_period_start = Column(DateTime(timezone=True), nullable=True)
|
||||
allow_overage = Column(Boolean, nullable=False, default=False, server_default="0")
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class SubscriptionPlan(Base):
|
||||
"""Dynamically configurable subscription plan stored in the database.
|
||||
|
||||
Plans are shown on the public /pricing page and assigned to users via
|
||||
UserProfile.subscription_tier (which stores plan_id). On first start the
|
||||
four default plans are seeded from TIER_DEFAULTS in app/utils/subscription.py.
|
||||
"""
|
||||
|
||||
__tablename__ = "subscription_plans"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
plan_id = Column(String(50), unique=True, nullable=False, index=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
tagline = Column(String(255), nullable=True)
|
||||
|
||||
# Pricing
|
||||
price_monthly = Column(Float, nullable=False, default=0.0)
|
||||
price_yearly = Column(Float, nullable=False, default=0.0)
|
||||
trial_days = Column(Integer, nullable=False, default=0)
|
||||
|
||||
# Volume limits (0 = unlimited)
|
||||
lifetime_file_limit = Column(Integer, nullable=False, default=0)
|
||||
daily_upload_limit = Column(Integer, nullable=False, default=0)
|
||||
monthly_upload_limit = Column(Integer, nullable=False, default=0)
|
||||
max_storage_destinations = Column(Integer, nullable=False, default=0)
|
||||
max_ocr_pages_monthly = Column(Integer, nullable=False, default=0)
|
||||
max_file_size_mb = Column(Integer, nullable=False, default=0)
|
||||
max_mailboxes = Column(Integer, nullable=False, default=0)
|
||||
|
||||
# Overage configuration
|
||||
overage_percent = Column(Integer, nullable=False, default=20)
|
||||
allow_overage_billing = Column(Boolean, nullable=False, default=False)
|
||||
overage_price_per_doc = Column(Float, nullable=True)
|
||||
overage_price_per_ocr_page = Column(Float, nullable=True)
|
||||
|
||||
# Display / marketing
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
is_highlighted = Column(Boolean, nullable=False, default=False)
|
||||
badge_text = Column(String(50), nullable=True)
|
||||
cta_text = Column(String(100), nullable=False, default="Get started")
|
||||
sort_order = Column(Integer, nullable=False, default=0)
|
||||
features = Column(Text, nullable=True) # JSON-encoded list[str]
|
||||
api_access = Column(Boolean, nullable=False, default=False)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
+162
-48
@@ -3,9 +3,9 @@ Subscription tier definitions and enforcement utilities for DocuElevate SaaS.
|
||||
|
||||
Four tiers (prices ex-VAT; German customers +19 % MwSt):
|
||||
- free $0/mo — 50 lifetime docs, 150 lifetime OCR pages, 1 dest
|
||||
- starter $2.99/mo — 5/day, 50/mo, 300 OCR pp/mo, 2 dests, 1 mailbox
|
||||
- professional $5.99/mo — 15/day, 150/mo, 750 OCR pp/mo, 5 dests, 3 mailboxes
|
||||
- business $7.99/mo — 30/day, 300/mo, 1500 OCR pp/mo, 10 dests, unlimited mailboxes
|
||||
- starter $2.99/mo — 50/mo, 300 OCR pp/mo, 2 dests, 1 mailbox
|
||||
- professional $5.99/mo — 150/mo, 750 OCR pp/mo, 5 dests, 3 mailboxes
|
||||
- business $7.99/mo — 300/mo, 1500 OCR pp/mo, 10 dests, unlimited mailboxes
|
||||
|
||||
Limits use 0 to represent "unlimited".
|
||||
All paid tiers include a 30-day free trial (trial_days field).
|
||||
@@ -34,13 +34,15 @@ from typing import Any
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tier catalogue
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TIERS: dict[str, dict[str, Any]] = {
|
||||
TIER_DEFAULTS: dict[str, dict[str, Any]] = {
|
||||
"free": {
|
||||
"id": "free",
|
||||
"name": "Free",
|
||||
@@ -161,6 +163,9 @@ TIERS: dict[str, dict[str, Any]] = {
|
||||
},
|
||||
}
|
||||
|
||||
# Backward-compatible alias
|
||||
TIERS = TIER_DEFAULTS
|
||||
|
||||
# Display order for the pricing page
|
||||
TIER_ORDER = ["free", "starter", "professional", "business"]
|
||||
|
||||
@@ -168,19 +173,135 @@ TIER_ORDER = ["free", "starter", "professional", "business"]
|
||||
DEFAULT_TIER = "free"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DB → dict conversion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _plan_to_dict(plan: Any) -> dict[str, Any]:
|
||||
"""Convert a SubscriptionPlan ORM object to the same dict shape as TIER_DEFAULTS entries."""
|
||||
import json
|
||||
|
||||
features: list[str] = []
|
||||
if plan.features:
|
||||
try:
|
||||
features = json.loads(plan.features)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
features = []
|
||||
return {
|
||||
"id": plan.plan_id,
|
||||
"name": plan.name,
|
||||
"tagline": plan.tagline or "",
|
||||
"price_monthly": plan.price_monthly,
|
||||
"price_yearly": plan.price_yearly,
|
||||
"trial_days": plan.trial_days,
|
||||
"highlight": plan.is_highlighted,
|
||||
"lifetime_file_limit": plan.lifetime_file_limit,
|
||||
"daily_upload_limit": plan.daily_upload_limit,
|
||||
"monthly_upload_limit": plan.monthly_upload_limit,
|
||||
"max_storage_destinations": plan.max_storage_destinations,
|
||||
"max_ocr_pages_monthly": plan.max_ocr_pages_monthly,
|
||||
"max_file_size_mb": plan.max_file_size_mb,
|
||||
"max_mailboxes": plan.max_mailboxes,
|
||||
"api_access": plan.api_access,
|
||||
"features": features,
|
||||
"cta": plan.cta_text or "Get started",
|
||||
"badge": plan.badge_text,
|
||||
"overage_percent": plan.overage_percent,
|
||||
"allow_overage_billing": plan.allow_overage_billing,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Getters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_tier(tier_id: str) -> dict[str, Any]:
|
||||
"""Return tier config dict; falls back to *free* for unknown ids."""
|
||||
return TIERS.get(tier_id, TIERS["free"])
|
||||
def get_tier(tier_id: str, db: Session | None = None) -> dict[str, Any]:
|
||||
"""Return plan config dict; DB-first when db is provided, falls back to TIER_DEFAULTS."""
|
||||
if db is not None:
|
||||
from app.models import SubscriptionPlan
|
||||
|
||||
plan = (
|
||||
db.query(SubscriptionPlan)
|
||||
.filter(
|
||||
SubscriptionPlan.plan_id == tier_id,
|
||||
SubscriptionPlan.is_active.is_(True),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if plan is not None:
|
||||
return _plan_to_dict(plan)
|
||||
return TIER_DEFAULTS.get(tier_id, TIER_DEFAULTS["free"])
|
||||
|
||||
|
||||
def get_all_tiers() -> list[dict[str, Any]]:
|
||||
"""Return tiers in display order."""
|
||||
return [TIERS[tid] for tid in TIER_ORDER]
|
||||
def get_all_tiers(db: Session | None = None) -> list[dict[str, Any]]:
|
||||
"""Return plans in display order; DB-first when db is provided."""
|
||||
if db is not None:
|
||||
from app.models import SubscriptionPlan
|
||||
|
||||
plans = (
|
||||
db.query(SubscriptionPlan)
|
||||
.filter(SubscriptionPlan.is_active.is_(True))
|
||||
.order_by(SubscriptionPlan.sort_order)
|
||||
.all()
|
||||
)
|
||||
if plans:
|
||||
return [_plan_to_dict(p) for p in plans]
|
||||
return [TIER_DEFAULTS[tid] for tid in TIER_ORDER]
|
||||
|
||||
|
||||
def seed_default_plans(db: Session) -> int:
|
||||
"""Seed subscription_plans table from TIER_DEFAULTS if the table is empty.
|
||||
|
||||
Called at application startup. Returns the number of plans inserted (0 if already seeded).
|
||||
"""
|
||||
import json
|
||||
|
||||
from app.models import SubscriptionPlan
|
||||
|
||||
try:
|
||||
if db.query(SubscriptionPlan).count() > 0:
|
||||
return 0
|
||||
except Exception:
|
||||
return 0 # table may not exist yet during first migration
|
||||
|
||||
inserted = 0
|
||||
for sort_order, (_, tier) in enumerate(TIER_DEFAULTS.items()):
|
||||
plan = SubscriptionPlan(
|
||||
plan_id=tier["id"],
|
||||
name=tier["name"],
|
||||
tagline=tier.get("tagline", ""),
|
||||
price_monthly=tier["price_monthly"],
|
||||
price_yearly=tier["price_yearly"],
|
||||
trial_days=tier.get("trial_days", 0),
|
||||
is_highlighted=tier.get("highlight", False),
|
||||
badge_text=tier.get("badge"),
|
||||
cta_text=tier.get("cta", "Get started"),
|
||||
lifetime_file_limit=tier["lifetime_file_limit"],
|
||||
daily_upload_limit=tier["daily_upload_limit"],
|
||||
monthly_upload_limit=tier["monthly_upload_limit"],
|
||||
max_storage_destinations=tier["max_storage_destinations"],
|
||||
max_ocr_pages_monthly=tier["max_ocr_pages_monthly"],
|
||||
max_file_size_mb=tier["max_file_size_mb"],
|
||||
max_mailboxes=tier.get("max_mailboxes", 0),
|
||||
api_access=tier.get("api_access", False),
|
||||
features=json.dumps(tier.get("features", [])),
|
||||
overage_percent=20,
|
||||
allow_overage_billing=False,
|
||||
sort_order=sort_order,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(plan)
|
||||
inserted += 1
|
||||
try:
|
||||
db.commit()
|
||||
logger.info("Seeded %d default subscription plans", inserted)
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
logger.error("Failed to seed subscription plans: %s", exc)
|
||||
inserted = 0
|
||||
return inserted
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -235,11 +356,7 @@ def get_month_file_count(db: Session, owner_id: str) -> int:
|
||||
|
||||
|
||||
def get_year_file_count(db: Session, owner_id: str, period_start: datetime) -> int:
|
||||
"""Files processed since the start of the current subscription period.
|
||||
|
||||
Used for yearly-subscription carry-over: compares cumulative usage against the
|
||||
cumulative monthly budget since the annual period started.
|
||||
"""
|
||||
"""Files processed since the start of the current annual subscription period."""
|
||||
from app.models import FileRecord
|
||||
|
||||
return _scalar_count(
|
||||
@@ -252,7 +369,7 @@ def get_year_file_count(db: Session, owner_id: str, period_start: datetime) -> i
|
||||
|
||||
|
||||
def _months_elapsed(period_start: datetime, now: datetime) -> int:
|
||||
"""Calendar months elapsed since *period_start*, clamped to 1–12."""
|
||||
"""Calendar months elapsed since *period_start*, clamped to [1, 12]."""
|
||||
elapsed = (now.year - period_start.year) * 12 + (now.month - period_start.month) + 1
|
||||
return max(1, min(elapsed, 12))
|
||||
|
||||
@@ -275,37 +392,33 @@ class QuotaExceeded(Exception):
|
||||
def check_upload_allowed(db: Session, owner_id: str | None, tier_id: str | None) -> None:
|
||||
"""Raise :class:`QuotaExceeded` if this user is not allowed to upload another file.
|
||||
|
||||
When *owner_id* or *tier_id* is ``None`` (e.g. single-user mode) the check
|
||||
is skipped entirely.
|
||||
Skipped entirely when *owner_id* or *tier_id* is ``None`` (single-user mode).
|
||||
|
||||
Enforcement model
|
||||
-----------------
|
||||
* **Announced limit** — the quota shown to users on the pricing page
|
||||
(``monthly_upload_limit`` in TIERS).
|
||||
* **Enforcement limit** — ``announced × settings.subscription_overage_factor``
|
||||
(default 1.33). A 150-doc/month plan is therefore enforced at 200 docs,
|
||||
giving users a soft buffer before they see an error.
|
||||
* **Overage flag** — if ``UserProfile.allow_overage`` is ``True`` the check
|
||||
is bypassed entirely. Usage is still tracked so future billing can charge
|
||||
for overages. (Not yet exposed in the admin UI.)
|
||||
* **Announced limit** — the quota shown on the pricing page
|
||||
(``monthly_upload_limit`` in the plan).
|
||||
* **Overage buffer** — each plan stores ``overage_percent`` (default 20).
|
||||
Enforcement = announced × (1 + overage_percent / 100). A 150-doc/month
|
||||
plan with 20 % buffer is enforced at 180 docs.
|
||||
* **Overage flag** — if ``UserProfile.allow_overage`` is ``True``, quota
|
||||
checks are bypassed entirely so usage can be billed retroactively.
|
||||
(Not yet exposed in the admin UI — baked in for future billing.)
|
||||
* **Yearly carry-over** — yearly subscribers have cumulative quota:
|
||||
effective limit = ``monthly_limit × months_elapsed × overage_factor``.
|
||||
effective limit = monthly_limit × months_elapsed × overage_factor.
|
||||
Unused quota from earlier months rolls forward automatically.
|
||||
|
||||
No daily cap is enforced — ``daily_upload_limit`` in TIERS is kept as
|
||||
informational data only.
|
||||
* **No daily cap** — ``daily_upload_limit`` is kept for display purposes
|
||||
only; it is never enforced.
|
||||
"""
|
||||
if owner_id is None or tier_id is None:
|
||||
return
|
||||
|
||||
tier = get_tier(tier_id)
|
||||
tier = get_tier(tier_id, db)
|
||||
|
||||
# Resolve overage factor from config
|
||||
from app.config import settings
|
||||
# Per-plan overage_percent overrides global config default
|
||||
overage_percent: int = tier.get("overage_percent", settings.subscription_overage_percent)
|
||||
overage_factor: float = 1.0 + overage_percent / 100.0
|
||||
|
||||
overage_factor: float = settings.subscription_overage_factor
|
||||
|
||||
# Fetch profile for billing cycle and overage permission
|
||||
from app.models import UserProfile
|
||||
|
||||
profile = db.query(UserProfile).filter(UserProfile.user_id == owner_id).first()
|
||||
@@ -314,7 +427,7 @@ def check_upload_allowed(db: Session, owner_id: str | None, tier_id: str | None)
|
||||
period_start: datetime | None = profile.subscription_period_start if profile else None
|
||||
|
||||
# 1. Lifetime file cap (free tier) — always enforced regardless of overage flag
|
||||
lifetime_limit = tier["lifetime_file_limit"]
|
||||
lifetime_limit: int = tier["lifetime_file_limit"]
|
||||
if lifetime_limit > 0:
|
||||
enforcement_limit = int(lifetime_limit * overage_factor)
|
||||
count = get_lifetime_file_count(db, owner_id)
|
||||
@@ -327,14 +440,13 @@ def check_upload_allowed(db: Session, owner_id: str | None, tier_id: str | None)
|
||||
current_value=count,
|
||||
)
|
||||
|
||||
# 2. Monthly cap — skipped entirely when overage is enabled for this user
|
||||
# 2. Monthly cap — bypassed when allow_overage is True (future billing)
|
||||
if allow_overage:
|
||||
return
|
||||
|
||||
monthly_limit = tier["monthly_upload_limit"]
|
||||
monthly_limit: int = tier["monthly_upload_limit"]
|
||||
if monthly_limit > 0:
|
||||
if billing_cycle == "yearly" and period_start is not None:
|
||||
# Carry-over: cumulative usage vs cumulative budget within the subscription year
|
||||
now = datetime.now(timezone.utc)
|
||||
months = _months_elapsed(period_start, now)
|
||||
cumulative_budget = int(monthly_limit * months * overage_factor)
|
||||
@@ -342,14 +454,13 @@ def check_upload_allowed(db: Session, owner_id: str | None, tier_id: str | None)
|
||||
if cumulative_used >= cumulative_budget:
|
||||
raise QuotaExceeded(
|
||||
f"Annual document quota for the {tier['name']} plan has been reached. "
|
||||
"Unused monthly quota carries forward — your limit will reset on your "
|
||||
"annual renewal date, or you can upgrade your plan.",
|
||||
"Unused monthly quota carries forward — your limit resets on your annual "
|
||||
"renewal date, or you can upgrade your plan.",
|
||||
limit_type="monthly",
|
||||
limit_value=monthly_limit,
|
||||
current_value=cumulative_used,
|
||||
)
|
||||
else:
|
||||
# Monthly billing: check current calendar month only
|
||||
count = get_month_file_count(db, owner_id)
|
||||
enforcement_limit = int(monthly_limit * overage_factor)
|
||||
if count >= enforcement_limit:
|
||||
@@ -360,9 +471,6 @@ def check_upload_allowed(db: Session, owner_id: str | None, tier_id: str | None)
|
||||
limit_value=monthly_limit,
|
||||
current_value=count,
|
||||
)
|
||||
limit_value=monthly_limit,
|
||||
current_value=count,
|
||||
)
|
||||
|
||||
|
||||
def get_user_tier_id(db: Session, owner_id: str | None) -> str:
|
||||
@@ -378,9 +486,15 @@ def get_user_tier_id(db: Session, owner_id: str | None) -> str:
|
||||
|
||||
|
||||
def get_user_usage(db: Session, owner_id: str) -> dict[str, int]:
|
||||
"""Return a dict with lifetime / daily / monthly file counts for *owner_id*."""
|
||||
return {
|
||||
"""Return file counts for *owner_id*, including carry-over data for yearly plans."""
|
||||
from app.models import UserProfile
|
||||
|
||||
profile = db.query(UserProfile).filter(UserProfile.user_id == owner_id).first()
|
||||
result: dict[str, int] = {
|
||||
"lifetime": get_lifetime_file_count(db, owner_id),
|
||||
"today": get_today_file_count(db, owner_id),
|
||||
"month": get_month_file_count(db, owner_id),
|
||||
}
|
||||
if profile and (profile.subscription_billing_cycle or "monthly") == "yearly" and profile.subscription_period_start:
|
||||
result["year_to_date"] = get_year_file_count(db, owner_id, profile.subscription_period_start)
|
||||
return result
|
||||
|
||||
@@ -14,11 +14,12 @@ from app.views.general import router as general_router
|
||||
from app.views.google_drive import router as google_drive_router
|
||||
from app.views.license_routes import router as license_router # Add the license router
|
||||
from app.views.onedrive import router as onedrive_router
|
||||
from app.views.plans import router as plans_router # Admin Plan Designer
|
||||
from app.views.queue import router as queue_router
|
||||
from app.views.search import router as search_router
|
||||
from app.views.settings import router as settings_router
|
||||
from app.views.status import router as status_router
|
||||
from app.views.subscriptions import router as subscriptions_router
|
||||
from app.views.subscriptions import router as subscriptions_router # Pricing + subscription pages
|
||||
from app.views.wizard import router as wizard_router
|
||||
|
||||
# Create a main router that includes all the view routers
|
||||
@@ -37,3 +38,4 @@ router.include_router(filemanager_router)
|
||||
router.include_router(search_router)
|
||||
router.include_router(queue_router)
|
||||
router.include_router(subscriptions_router) # Pricing + subscription pages
|
||||
router.include_router(plans_router) # Admin Plan Designer
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""View route for the admin Plan Designer page."""
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.routing import APIRouter
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.auth import require_login
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="frontend/templates")
|
||||
|
||||
|
||||
@router.get("/admin/plans", response_class=HTMLResponse)
|
||||
@require_login
|
||||
async def plan_designer(request: Request) -> HTMLResponse:
|
||||
"""Admin Plan Designer page."""
|
||||
return templates.TemplateResponse("admin_plans.html", {"request": request})
|
||||
@@ -18,9 +18,9 @@ router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/pricing", include_in_schema=False)
|
||||
async def pricing_page(request: Request):
|
||||
async def pricing_page(request: Request, db: Session = Depends(get_db)):
|
||||
"""Public-facing pricing and plans page."""
|
||||
tiers = get_all_tiers()
|
||||
tiers = get_all_tiers(db)
|
||||
return templates.TemplateResponse(
|
||||
"pricing.html",
|
||||
{
|
||||
@@ -47,8 +47,8 @@ async def my_subscription_page(request: Request, db: Session = Depends(get_db)):
|
||||
tier_id = "business"
|
||||
usage = None
|
||||
|
||||
tier = get_tier(tier_id)
|
||||
all_tiers = get_all_tiers()
|
||||
tier = get_tier(tier_id, db)
|
||||
all_tiers = get_all_tiers(db)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"subscription.html",
|
||||
|
||||
+74
-108
@@ -1,151 +1,117 @@
|
||||
# Subscription Tiers
|
||||
|
||||
DocuElevate operates as a SaaS platform with four subscription tiers. When
|
||||
`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.
|
||||
DocuElevate uses database-backed subscription plans that are fully configurable by admins via the **Plan Designer** at `/admin/plans`. Four default tiers are seeded automatically on first startup.
|
||||
|
||||
---
|
||||
## Default Plans
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|
|
||||
| **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 |
|
||||
> Prices ex-VAT. German customers add 19% MwSt.
|
||||
|
||||
\* Free tier is capped by the **lifetime** limit of 25 files; there is no
|
||||
separate daily or monthly cap on top of that.
|
||||
All paid plans include a **30-day free trial**.
|
||||
|
||||
---
|
||||
## 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
|
||||
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.
|
||||
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.
|
||||
|
||||
There is no per-day or per-month cap: the user can use all 25 files in a
|
||||
single day if they wish.
|
||||
## Overage Buffer
|
||||
|
||||
---
|
||||
### 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
|
||||
processing. Provides a meaningful step up from the free tier without a high
|
||||
price commitment.
|
||||
### Per-Plan vs. Global Buffer
|
||||
|
||||
- 10 documents/day, 100 documents/month
|
||||
- 3 storage destinations (e.g. Dropbox + Google Drive + Nextcloud)
|
||||
- 500 OCR pages/month
|
||||
- Email ingestion and API access included
|
||||
Each plan has its own `overage_percent` field (set in the Plan Designer). There is also a global fallback: `settings.subscription_overage_percent` (default: 20, range: 0–200), which applies when a plan does not have an explicit value.
|
||||
|
||||
### 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
|
||||
flexibility.
|
||||
## Yearly Billing & Carry-Over
|
||||
|
||||
- 50 documents/day, 500 documents/month
|
||||
- 10 storage destinations
|
||||
- 2 500 OCR pages/month
|
||||
- All processing steps, webhooks, and priority email support
|
||||
When a user's `subscription_billing_cycle` is set to `yearly`:
|
||||
|
||||
### 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 1–2, 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
|
||||
support.
|
||||
## No Daily Cap
|
||||
|
||||
- Unlimited documents and storage destinations
|
||||
- Unlimited OCR pages
|
||||
- Unlimited file size
|
||||
- Custom integrations and dedicated support
|
||||
`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).
|
||||
|
||||
> **Contact Sales** for the Business tier — email
|
||||
> `sales@docuelevate.io` with subject "Business Plan Enquiry".
|
||||
## allow_overage Flag
|
||||
|
||||
---
|
||||
Setting `UserProfile.allow_overage = True` bypasses monthly quota checks entirely for that user. Usage is still tracked so future billing integrations can charge retroactively. This field is not yet exposed in the admin UI.
|
||||
|
||||
## Limit Enforcement
|
||||
## Plan Designer
|
||||
|
||||
Limits are enforced in real-time at the upload endpoint
|
||||
(`POST /api/ui-upload`). When a user exceeds any quota:
|
||||
Navigate to `/admin/plans` (admin only) to:
|
||||
|
||||
1. The uploaded file is discarded.
|
||||
2. The endpoint returns **HTTP 402 Payment Required** with a human-readable
|
||||
`detail` message explaining which limit was hit and how to upgrade.
|
||||
3. The upload UI displays the error message to the user.
|
||||
1. **View** all plans (active and inactive) with key stats.
|
||||
2. **Create** a new plan with a custom `plan_id` slug.
|
||||
3. **Edit** any plan's pricing, limits, overage buffer, features, and display settings.
|
||||
4. **Reorder** plans using the up/down arrows (order reflects pricing page display order).
|
||||
5. **Delete** a plan (does not affect existing users assigned to it).
|
||||
6. **Restore Defaults** — seeds the four built-in plans (no-op if plans already exist).
|
||||
|
||||
Quota checks run in order:
|
||||
### Overage Designer
|
||||
|
||||
1. Lifetime file limit (free tier only)
|
||||
2. Daily file limit
|
||||
3. Monthly file limit
|
||||
The Plan Designer includes an overage slider (0–100%). The live preview shows:
|
||||
|
||||
---
|
||||
> "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
|
||||
**Admin → Users** page (`/admin/users`).
|
||||
## API Endpoints
|
||||
|
||||
1. Click **Edit** next to any user.
|
||||
2. Change the **Subscription Plan** dropdown.
|
||||
3. Click **Save Changes**.
|
||||
All plan endpoints are under `/api/plans/`.
|
||||
|
||||
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
|
||||
|
||||
Admins can also manage tiers via the REST API:
|
||||
### Example: List Active Plans
|
||||
|
||||
```bash
|
||||
# Update a user's subscription tier
|
||||
curl -X PUT /api/admin/users/user@example.com \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"subscription_tier": "professional", "is_blocked": false}'
|
||||
curl http://localhost:8000/api/plans/
|
||||
```
|
||||
|
||||
---
|
||||
### Example: Update a Plan's Monthly Limit
|
||||
|
||||
## Platform Statistics
|
||||
|
||||
Admins can view platform-wide statistics via:
|
||||
|
||||
- **Dashboard** (`/`) — shows total files, today, this month, unique users
|
||||
when `MULTI_USER_ENABLED=True`.
|
||||
- **API** — `GET /api/subscriptions/platform` returns aggregate file counts
|
||||
and per-tier user distribution.
|
||||
|
||||
---
|
||||
```bash
|
||||
curl -X PUT http://localhost:8000/api/plans/starter \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "Starter",
|
||||
"monthly_upload_limit": 75,
|
||||
"overage_percent": 15,
|
||||
...
|
||||
}'
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Subscription tiers are defined in `app/utils/subscription.py` in the `TIERS`
|
||||
dictionary. Pricing, limits, and feature lists are all set there.
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `SUBSCRIPTION_OVERAGE_PERCENT` | `20` | Global overage buffer (0–200). Per-plan setting overrides this. |
|
||||
|
||||
Single-user mode (`MULTI_USER_ENABLED=False`) bypasses all quota checks
|
||||
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).
|
||||
See `docs/ConfigurationGuide.md` for all available settings.
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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"
|
||||
>
|
||||
<option value="free">Free — 25 lifetime files</option>
|
||||
<option value="starter">Starter — $2.99/mo (5/day, 50/mo, 1 mailbox)</option>
|
||||
<option value="professional">Professional — $5.99/mo (15/day, 150/mo, 3 mailboxes)</option>
|
||||
<option value="business">Business — $7.99/mo (30/day, 300/mo, unlimited mailboxes)</option>
|
||||
<option value="starter">Starter — $2.99/mo (50/mo, 1 mailbox)</option>
|
||||
<option value="professional">Professional — $5.99/mo (150/mo, 3 mailboxes)</option>
|
||||
<option value="business">Business — $7.99/mo (300/mo, unlimited mailboxes)</option>
|
||||
</select>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
Sets the quota limits for this user. Limits are enforced on upload.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Billing cycle -->
|
||||
<div>
|
||||
<label for="modal-billing-cycle" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Billing Cycle
|
||||
</label>
|
||||
<select
|
||||
id="modal-billing-cycle"
|
||||
x-model="form.subscription_billing_cycle"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 bg-white"
|
||||
>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="yearly">Yearly</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Subscription period start (only shown for yearly) -->
|
||||
<div x-show="form.subscription_billing_cycle === 'yearly'" x-cloak>
|
||||
<label for="modal-period-start" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Subscription Period Start
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="modal-period-start"
|
||||
x-model="form.subscription_period_start"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
|
||||
/>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
Annual carry-over calculates from this date. Leave blank for monthly enforcement.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
@@ -439,6 +470,8 @@ function adminUsersApp() {
|
||||
notes: '',
|
||||
is_blocked: false,
|
||||
subscription_tier: 'free',
|
||||
subscription_billing_cycle: 'monthly',
|
||||
subscription_period_start: null,
|
||||
},
|
||||
|
||||
// Delete modal
|
||||
@@ -491,7 +524,7 @@ function adminUsersApp() {
|
||||
openCreateModal() {
|
||||
this.isCreate = true;
|
||||
this.modalTitle = 'Add User Profile';
|
||||
this.form = { user_id: '', display_name: '', daily_upload_limit: null, notes: '', is_blocked: false, 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;
|
||||
},
|
||||
|
||||
@@ -506,6 +539,8 @@ function adminUsersApp() {
|
||||
notes: user.notes || '',
|
||||
is_blocked: !!user.is_blocked,
|
||||
subscription_tier: user.subscription_tier || 'free',
|
||||
subscription_billing_cycle: user.subscription_billing_cycle || 'monthly',
|
||||
subscription_period_start: user.subscription_period_start ? user.subscription_period_start.substring(0, 10) : null,
|
||||
};
|
||||
this.modalOpen = true;
|
||||
},
|
||||
|
||||
@@ -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">
|
||||
<i class="fas fa-users w-4 mr-2 text-blue-500" aria-hidden="true"></i> Users
|
||||
</a>
|
||||
<a href="/admin/plans" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<i class="fas fa-layer-group w-4 mr-2 text-indigo-500" aria-hidden="true"></i> Plan Designer
|
||||
</a>
|
||||
<a href="/admin/credentials" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<i class="fas fa-key w-4 mr-2 text-yellow-500" aria-hidden="true"></i> Credentials
|
||||
</a>
|
||||
@@ -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">
|
||||
<i class="fas fa-users mr-2 text-blue-400" aria-hidden="true"></i> Users
|
||||
</a>
|
||||
<a href="/admin/plans" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">
|
||||
<i class="fas fa-layer-group mr-2 text-indigo-400" aria-hidden="true"></i> Plan Designer
|
||||
</a>
|
||||
<a href="/admin/credentials" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">
|
||||
<i class="fas fa-key mr-2 text-yellow-400" aria-hidden="true"></i> Credentials
|
||||
</a>
|
||||
|
||||
@@ -170,19 +170,6 @@
|
||||
{% endfor %}
|
||||
</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">
|
||||
<td class="px-6 py-3 text-sm text-gray-700">Files per month</td>
|
||||
{% 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
@@ -1,27 +1,24 @@
|
||||
"""Unit tests for the subscription tier utility module."""
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
from datetime import datetime, timezone, date
|
||||
|
||||
import pytest
|
||||
|
||||
from app.utils.subscription import (
|
||||
TIERS,
|
||||
TIER_ORDER,
|
||||
DEFAULT_TIER,
|
||||
get_tier,
|
||||
TIER_DEFAULTS,
|
||||
TIER_ORDER,
|
||||
TIERS,
|
||||
QuotaExceeded,
|
||||
_months_elapsed,
|
||||
check_upload_allowed,
|
||||
get_all_tiers,
|
||||
get_tier,
|
||||
get_user_tier_id,
|
||||
get_user_usage,
|
||||
check_upload_allowed,
|
||||
QuotaExceeded,
|
||||
get_lifetime_file_count,
|
||||
get_today_file_count,
|
||||
get_month_file_count,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Basic catalogue tests
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -69,12 +66,21 @@ def test_get_all_tiers_returns_four():
|
||||
@pytest.mark.unit
|
||||
def test_all_tiers_have_required_fields():
|
||||
required = [
|
||||
"id", "name", "tagline", "price_monthly", "price_yearly",
|
||||
"id",
|
||||
"name",
|
||||
"tagline",
|
||||
"price_monthly",
|
||||
"price_yearly",
|
||||
"trial_days",
|
||||
"lifetime_file_limit", "daily_upload_limit", "monthly_upload_limit",
|
||||
"max_storage_destinations", "max_ocr_pages_monthly", "max_file_size_mb",
|
||||
"lifetime_file_limit",
|
||||
"daily_upload_limit",
|
||||
"monthly_upload_limit",
|
||||
"max_storage_destinations",
|
||||
"max_ocr_pages_monthly",
|
||||
"max_file_size_mb",
|
||||
"max_mailboxes",
|
||||
"features", "cta",
|
||||
"features",
|
||||
"cta",
|
||||
]
|
||||
for tid, tier in TIERS.items():
|
||||
for field in required:
|
||||
@@ -103,18 +109,21 @@ def test_free_tier_has_no_mailboxes():
|
||||
def test_business_tier_has_highest_limits():
|
||||
"""Business tier must have the highest limits of all paid tiers."""
|
||||
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["daily_upload_limit"] == 30
|
||||
# no daily cap (0 = unlimited)
|
||||
assert t["daily_upload_limit"] == 0
|
||||
assert t["monthly_upload_limit"] == 300
|
||||
assert t["max_ocr_pages_monthly"] == 1500
|
||||
# unlimited mailboxes
|
||||
# unlimited mailboxes (0 = unlimited)
|
||||
assert t["max_mailboxes"] == 0
|
||||
# unlimited file size (0 = unlimited)
|
||||
assert t["max_file_size_mb"] == 0
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_mailbox_limits_increase_by_tier():
|
||||
"""Mailbox limits must increase across tiers: free=0, starter=1, professional=3, business=0(∞)."""
|
||||
"""Mailbox limits must increase across tiers: free=0, starter=1, professional=3, business=0(inf)."""
|
||||
assert TIERS["free"]["max_mailboxes"] == 0
|
||||
assert TIERS["starter"]["max_mailboxes"] == 1
|
||||
assert TIERS["professional"]["max_mailboxes"] == 3
|
||||
@@ -197,10 +206,16 @@ def test_check_upload_skipped_without_tier():
|
||||
|
||||
@pytest.mark.unit
|
||||
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()
|
||||
# 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:
|
||||
check_upload_allowed(db, "user@example.com", "free")
|
||||
|
||||
@@ -212,31 +227,34 @@ def test_check_upload_raises_when_lifetime_exceeded():
|
||||
@pytest.mark.unit
|
||||
def test_check_upload_passes_below_lifetime_limit():
|
||||
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
|
||||
|
||||
|
||||
@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
|
||||
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()
|
||||
|
||||
with patch("app.utils.subscription.get_lifetime_file_count", return_value=0), \
|
||||
patch("app.utils.subscription.get_today_file_count", return_value=0), \
|
||||
patch("app.utils.subscription.get_month_file_count", return_value=50):
|
||||
# UserProfile mock: no overage, monthly billing, no period_start
|
||||
profile_mock = MagicMock()
|
||||
profile_mock.allow_overage = False
|
||||
profile_mock.subscription_billing_cycle = "monthly"
|
||||
profile_mock.subscription_period_start = None
|
||||
db.query.return_value.filter.return_value.first.side_effect = [None, profile_mock]
|
||||
|
||||
with (
|
||||
patch("app.utils.subscription.settings") as mock_settings,
|
||||
patch("app.utils.subscription.get_lifetime_file_count", return_value=0),
|
||||
patch("app.utils.subscription.get_month_file_count", return_value=50),
|
||||
):
|
||||
mock_settings.subscription_overage_percent = 0
|
||||
with pytest.raises(QuotaExceeded) as exc_info:
|
||||
check_upload_allowed(db, "user@example.com", "starter")
|
||||
|
||||
@@ -245,27 +263,154 @@ def test_check_upload_raises_when_monthly_exceeded():
|
||||
|
||||
@pytest.mark.unit
|
||||
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()
|
||||
# Use counts well below Business limits (30/day, 300/mo)
|
||||
with patch("app.utils.subscription.get_lifetime_file_count", return_value=0), \
|
||||
patch("app.utils.subscription.get_today_file_count", return_value=10), \
|
||||
patch("app.utils.subscription.get_month_file_count", return_value=100):
|
||||
profile_mock = MagicMock()
|
||||
profile_mock.allow_overage = False
|
||||
profile_mock.subscription_billing_cycle = "monthly"
|
||||
profile_mock.subscription_period_start = None
|
||||
db.query.return_value.filter.return_value.first.side_effect = [None, profile_mock]
|
||||
|
||||
with (
|
||||
patch("app.utils.subscription.settings") as mock_settings,
|
||||
patch("app.utils.subscription.get_lifetime_file_count", return_value=0),
|
||||
patch("app.utils.subscription.get_month_file_count", return_value=100),
|
||||
):
|
||||
mock_settings.subscription_overage_percent = 0
|
||||
check_upload_allowed(db, "user@example.com", "business") # must not raise
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Overage buffer tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_check_upload_business_tier_raises_when_daily_exceeded():
|
||||
"""Business tier: should raise QuotaExceeded when daily limit (30) is hit."""
|
||||
def test_overage_percent_allows_buffer():
|
||||
"""Starter monthly=50, 20% buffer -> enforce at 60. count=55 should pass, count=61 should raise."""
|
||||
profile_mock = MagicMock()
|
||||
profile_mock.allow_overage = False
|
||||
profile_mock.subscription_billing_cycle = "monthly"
|
||||
profile_mock.subscription_period_start = None
|
||||
|
||||
db = MagicMock()
|
||||
db.query.return_value.filter.return_value.first.side_effect = [None, profile_mock]
|
||||
|
||||
with patch("app.utils.subscription.get_lifetime_file_count", return_value=0), \
|
||||
patch("app.utils.subscription.get_today_file_count", return_value=30):
|
||||
with (
|
||||
patch("app.utils.subscription.settings") as mock_settings,
|
||||
patch("app.utils.subscription.get_lifetime_file_count", return_value=0),
|
||||
patch("app.utils.subscription.get_month_file_count", return_value=55),
|
||||
):
|
||||
mock_settings.subscription_overage_percent = 20
|
||||
# count=55 < 60 (50*1.20) -> should NOT raise
|
||||
check_upload_allowed(db, "user@example.com", "starter")
|
||||
|
||||
# Reset mock for second call
|
||||
db2 = MagicMock()
|
||||
db2.query.return_value.filter.return_value.first.side_effect = [None, profile_mock]
|
||||
|
||||
with (
|
||||
patch("app.utils.subscription.settings") as mock_settings,
|
||||
patch("app.utils.subscription.get_lifetime_file_count", return_value=0),
|
||||
patch("app.utils.subscription.get_month_file_count", return_value=61),
|
||||
):
|
||||
mock_settings.subscription_overage_percent = 20
|
||||
# count=61 >= 60 -> should raise
|
||||
with pytest.raises(QuotaExceeded) as exc_info:
|
||||
check_upload_allowed(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
|
||||
def test_get_user_usage_returns_dict_with_correct_keys():
|
||||
db = MagicMock()
|
||||
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):
|
||||
# No profile -> monthly billing, no period_start
|
||||
db.query.return_value.filter.return_value.first.return_value = None
|
||||
|
||||
with (
|
||||
patch("app.utils.subscription.get_lifetime_file_count", return_value=10),
|
||||
patch("app.utils.subscription.get_today_file_count", return_value=2),
|
||||
patch("app.utils.subscription.get_month_file_count", return_value=8),
|
||||
):
|
||||
result = get_user_usage(db, "user@example.com")
|
||||
|
||||
assert result == {"lifetime": 10, "today": 2, "month": 8}
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_get_user_usage_includes_year_to_date_for_yearly():
|
||||
"""Yearly subscriber gets year_to_date key in usage dict."""
|
||||
db = MagicMock()
|
||||
profile_mock = MagicMock()
|
||||
profile_mock.subscription_billing_cycle = "yearly"
|
||||
period_start = datetime(2025, 1, 1, tzinfo=timezone.utc)
|
||||
profile_mock.subscription_period_start = period_start
|
||||
db.query.return_value.filter.return_value.first.return_value = profile_mock
|
||||
|
||||
with (
|
||||
patch("app.utils.subscription.get_lifetime_file_count", return_value=10),
|
||||
patch("app.utils.subscription.get_today_file_count", return_value=2),
|
||||
patch("app.utils.subscription.get_month_file_count", return_value=8),
|
||||
patch("app.utils.subscription.get_year_file_count", return_value=40),
|
||||
):
|
||||
result = get_user_usage(db, "user@example.com")
|
||||
|
||||
assert "year_to_date" in result
|
||||
assert result["year_to_date"] == 40
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TIERS / TIER_DEFAULTS alias
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_tiers_is_alias_for_tier_defaults():
|
||||
"""TIERS must be the same object as TIER_DEFAULTS (backward compat alias)."""
|
||||
assert TIERS is TIER_DEFAULTS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API: /api/subscriptions/tiers (integration-style, mocked app)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user