diff --git a/app/api/__init__.py b/app/api/__init__.py index 5645f160..ac916de2 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -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) diff --git a/app/api/admin_users.py b/app/api/admin_users.py index 2373ebc8..f80d463f 100644 --- a/app/api/admin_users.py +++ b/app/api/admin_users.py @@ -5,6 +5,7 @@ administrators can inspect, configure, and manage users in multi-user mode. """ import logging +from datetime import datetime from typing import Annotated, Any from fastapi import APIRouter, Depends, HTTPException, Query, Request, status @@ -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 diff --git a/app/api/plans.py b/app/api/plans.py new file mode 100644 index 00000000..844b9326 --- /dev/null +++ b/app/api/plans.py @@ -0,0 +1,273 @@ +"""REST API for subscription plan CRUD. + +Endpoints: + GET /api/plans/ — list active plans (public) + GET /api/plans/admin — list all plans inc. inactive (admin only) + POST /api/plans/ — create plan (admin only) + GET /api/plans/{plan_id} — get single active plan (public) + PUT /api/plans/{plan_id} — update plan (admin only) + DELETE /api/plans/{plan_id} — delete plan (admin only) + POST /api/plans/seed — seed default plans (admin only) + POST /api/plans/reorder — set sort_order for multiple plans (admin only) +""" + +import json +import logging +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models import SubscriptionPlan + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/plans", tags=["plans"]) + +DbSession = Annotated[Session, Depends(get_db)] + + +# --------------------------------------------------------------------------- +# Auth helper (admin-only) +# --------------------------------------------------------------------------- + + +def _require_admin(request: Request) -> dict: + """Ensure the caller is an admin. Raises 403 otherwise.""" + user = request.session.get("user") + if not user or not user.get("is_admin"): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required") + return user + + +AdminUser = Annotated[dict, Depends(_require_admin)] + + +# --------------------------------------------------------------------------- +# Pydantic schemas +# --------------------------------------------------------------------------- + + +class PlanUpsert(BaseModel): + """Body for creating or updating a subscription plan.""" + + name: str + tagline: str | None = None + price_monthly: float = 0.0 + price_yearly: float = 0.0 + trial_days: int = 0 + lifetime_file_limit: int = 0 + daily_upload_limit: int = 0 + monthly_upload_limit: int = 0 + max_storage_destinations: int = 0 + max_ocr_pages_monthly: int = 0 + max_file_size_mb: int = 0 + max_mailboxes: int = 0 + overage_percent: int = Field(default=20, ge=0, le=200) + allow_overage_billing: bool = False + overage_price_per_doc: float | None = None + overage_price_per_ocr_page: float | None = None + is_active: bool = True + is_highlighted: bool = False + badge_text: str | None = None + cta_text: str = "Get started" + sort_order: int = 0 + features: list[str] = [] + api_access: bool = False + + +class ReorderBody(BaseModel): + """Body for reordering plans.""" + + order: list[str] + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _plan_to_response(plan: SubscriptionPlan) -> dict[str, Any]: + features: list[str] = [] + if plan.features: + try: + features = json.loads(plan.features) + except (json.JSONDecodeError, TypeError): + features = [] + return { + "id": plan.id, + "plan_id": plan.plan_id, + "name": plan.name, + "tagline": plan.tagline, + "price_monthly": plan.price_monthly, + "price_yearly": plan.price_yearly, + "trial_days": plan.trial_days, + "lifetime_file_limit": plan.lifetime_file_limit, + "daily_upload_limit": plan.daily_upload_limit, + "monthly_upload_limit": plan.monthly_upload_limit, + "max_storage_destinations": plan.max_storage_destinations, + "max_ocr_pages_monthly": plan.max_ocr_pages_monthly, + "max_file_size_mb": plan.max_file_size_mb, + "max_mailboxes": plan.max_mailboxes, + "overage_percent": plan.overage_percent, + "allow_overage_billing": plan.allow_overage_billing, + "overage_price_per_doc": plan.overage_price_per_doc, + "overage_price_per_ocr_page": plan.overage_price_per_ocr_page, + "is_active": plan.is_active, + "is_highlighted": plan.is_highlighted, + "badge_text": plan.badge_text, + "cta_text": plan.cta_text, + "sort_order": plan.sort_order, + "features": features, + "api_access": plan.api_access, + "created_at": plan.created_at.isoformat() if plan.created_at else None, + "updated_at": plan.updated_at.isoformat() if plan.updated_at else None, + } + + +def _apply_body(plan: SubscriptionPlan, body: PlanUpsert) -> None: + """Apply PlanUpsert fields onto a SubscriptionPlan ORM object.""" + plan.name = body.name + plan.tagline = body.tagline + plan.price_monthly = body.price_monthly + plan.price_yearly = body.price_yearly + plan.trial_days = body.trial_days + plan.lifetime_file_limit = body.lifetime_file_limit + plan.daily_upload_limit = body.daily_upload_limit + plan.monthly_upload_limit = body.monthly_upload_limit + plan.max_storage_destinations = body.max_storage_destinations + plan.max_ocr_pages_monthly = body.max_ocr_pages_monthly + plan.max_file_size_mb = body.max_file_size_mb + plan.max_mailboxes = body.max_mailboxes + plan.overage_percent = body.overage_percent + plan.allow_overage_billing = body.allow_overage_billing + plan.overage_price_per_doc = body.overage_price_per_doc + plan.overage_price_per_ocr_page = body.overage_price_per_ocr_page + plan.is_active = body.is_active + plan.is_highlighted = body.is_highlighted + plan.badge_text = body.badge_text + plan.cta_text = body.cta_text + plan.sort_order = body.sort_order + plan.features = json.dumps(body.features) + plan.api_access = body.api_access + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + + +@router.get("/", summary="List active plans (public)") +def list_active_plans(db: DbSession) -> dict[str, Any]: + """Return all active plans in sort order. Public endpoint — no auth required.""" + plans = ( + db.query(SubscriptionPlan) + .filter(SubscriptionPlan.is_active.is_(True)) + .order_by(SubscriptionPlan.sort_order) + .all() + ) + return {"plans": [_plan_to_response(p) for p in plans]} + + +@router.get("/admin", summary="List all plans including inactive (admin only)") +def list_all_plans(db: DbSession, _admin: AdminUser) -> dict[str, Any]: + """Return all plans (active and inactive) in sort order. Admin only.""" + plans = db.query(SubscriptionPlan).order_by(SubscriptionPlan.sort_order).all() + return {"plans": [_plan_to_response(p) for p in plans]} + + +@router.post("/seed", summary="Seed default plans (admin only)", status_code=status.HTTP_200_OK) +def seed_plans(db: DbSession, _admin: AdminUser) -> dict[str, Any]: + """Seed the subscription_plans table from TIER_DEFAULTS. No-op if plans already exist.""" + from app.utils.subscription import seed_default_plans + + inserted = seed_default_plans(db) + return {"inserted": inserted, "message": f"Seeded {inserted} default plan(s)."} + + +@router.post("/reorder", summary="Reorder plans (admin only)") +def reorder_plans(body: ReorderBody, db: DbSession, _admin: AdminUser) -> dict[str, Any]: + """Update sort_order for each plan_id in *body.order* (position = index in list).""" + updated = 0 + for sort_order, plan_id in enumerate(body.order): + plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.plan_id == plan_id).first() + if plan: + plan.sort_order = sort_order + updated += 1 + try: + db.commit() + except Exception: + db.rollback() + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to reorder plans") + return {"updated": updated} + + +@router.post("/", summary="Create a new plan (admin only)", status_code=status.HTTP_201_CREATED) +def create_plan(plan_id: str, body: PlanUpsert, db: DbSession, _admin: AdminUser) -> dict[str, Any]: + """Create a new subscription plan with the given *plan_id* slug.""" + existing = db.query(SubscriptionPlan).filter(SubscriptionPlan.plan_id == plan_id).first() + if existing: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Plan '{plan_id}' already exists.", + ) + plan = SubscriptionPlan(plan_id=plan_id) + _apply_body(plan, body) + db.add(plan) + try: + db.commit() + db.refresh(plan) + except Exception: + db.rollback() + raise + logger.info("Admin created subscription plan '%s'", plan_id) + return _plan_to_response(plan) + + +@router.get("/{plan_id}", summary="Get a single active plan (public)") +def get_plan(plan_id: str, db: DbSession) -> dict[str, Any]: + """Return a single active plan by plan_id. Public endpoint.""" + plan = ( + db.query(SubscriptionPlan) + .filter( + SubscriptionPlan.plan_id == plan_id, + SubscriptionPlan.is_active.is_(True), + ) + .first() + ) + if not plan: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Plan '{plan_id}' not found.") + return _plan_to_response(plan) + + +@router.put("/{plan_id}", summary="Update an existing plan (admin only)") +def update_plan(plan_id: str, body: PlanUpsert, db: DbSession, _admin: AdminUser) -> dict[str, Any]: + """Update an existing subscription plan. Admin only.""" + plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.plan_id == plan_id).first() + if not plan: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Plan '{plan_id}' not found.") + _apply_body(plan, body) + try: + db.commit() + db.refresh(plan) + except Exception: + db.rollback() + raise + logger.info("Admin updated subscription plan '%s'", plan_id) + return _plan_to_response(plan) + + +@router.delete("/{plan_id}", summary="Delete a plan (admin only)", status_code=status.HTTP_204_NO_CONTENT) +def delete_plan(plan_id: str, db: DbSession, _admin: AdminUser) -> None: + """Delete a subscription plan. Admin only.""" + plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.plan_id == plan_id).first() + if not plan: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Plan '{plan_id}' not found.") + try: + db.delete(plan) + db.commit() + except Exception: + db.rollback() + raise + logger.info("Admin deleted subscription plan '%s'", plan_id) diff --git a/app/config.py b/app/config.py index b678a97e..d0322e82 100644 --- a/app/config.py +++ b/app/config.py @@ -153,6 +153,19 @@ class Settings(BaseSettings): ), ) + subscription_overage_percent: int = Field( + default=20, + ge=0, + le=200, + description=( + "Soft-limit overage buffer in percent (0–200). The announced monthly quota is " + "increased by this percentage for actual enforcement. E.g. 20 means a 150-doc/month " + "plan enforces at 180 docs (150 × 1.20). Set 0 to enforce exactly at the announced " + "limit. Per-plan overage_percent (set in Plan Designer) overrides this global default. " + "Default: 20." + ), + ) + # Authentik authentik_client_id: Optional[str] = None authentik_client_secret: Optional[str] = None diff --git a/app/main.py b/app/main.py index 10f305d2..5f39ae2c 100644 --- a/app/main.py +++ b/app/main.py @@ -99,6 +99,19 @@ async def lifespan(app: FastAPI): # Send startup notification notify_startup() + # Seed default subscription plans if none exist + try: + from app.database import SessionLocal as _SessionLocal + from app.utils.subscription import seed_default_plans as _seed_plans + + _db_seed = _SessionLocal() + try: + _seed_plans(_db_seed) + finally: + _db_seed.close() + except Exception: + logging.debug("Subscription plan seeding skipped — DB may not be ready yet") # noqa: S110 + # Application is now running yield diff --git a/app/models.py b/app/models.py index e0aefa57..b5857bb5 100644 --- a/app/models.py +++ b/app/models.py @@ -1,6 +1,6 @@ # app/models.py -from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func +from sqlalchemy import Boolean, Column, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint, func from app.database import Base @@ -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()) diff --git a/app/utils/subscription.py b/app/utils/subscription.py index 8723252b..d7dd92c0 100644 --- a/app/utils/subscription.py +++ b/app/utils/subscription.py @@ -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 diff --git a/app/views/__init__.py b/app/views/__init__.py index 01cc7f91..75eafbfb 100644 --- a/app/views/__init__.py +++ b/app/views/__init__.py @@ -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 diff --git a/app/views/plans.py b/app/views/plans.py new file mode 100644 index 00000000..05c00451 --- /dev/null +++ b/app/views/plans.py @@ -0,0 +1,18 @@ +"""View route for the admin Plan Designer page.""" + +from fastapi import Request +from fastapi.responses import HTMLResponse +from fastapi.routing import APIRouter +from fastapi.templating import Jinja2Templates + +from app.auth import require_login + +router = APIRouter() +templates = Jinja2Templates(directory="frontend/templates") + + +@router.get("/admin/plans", response_class=HTMLResponse) +@require_login +async def plan_designer(request: Request) -> HTMLResponse: + """Admin Plan Designer page.""" + return templates.TemplateResponse("admin_plans.html", {"request": request}) diff --git a/app/views/subscriptions.py b/app/views/subscriptions.py index dcd644aa..f30e8837 100644 --- a/app/views/subscriptions.py +++ b/app/views/subscriptions.py @@ -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", diff --git a/docs/SubscriptionTiers.md b/docs/SubscriptionTiers.md index 9874562f..d013fff7 100644 --- a/docs/SubscriptionTiers.md +++ b/docs/SubscriptionTiers.md @@ -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=` | 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. diff --git a/frontend/templates/admin_plans.html b/frontend/templates/admin_plans.html new file mode 100644 index 00000000..b0f1b098 --- /dev/null +++ b/frontend/templates/admin_plans.html @@ -0,0 +1,608 @@ +{% extends "base.html" %} + +{% block title %}Plan Designer — DocuElevate Admin{% endblock %} + +{% block content %} +
+ + +
+
+

Plan Designer

+

Manage subscription plans shown on the public pricing page.

+
+
+ + +
+
+ + +
+

About the Overage Buffer

+

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

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

Loading plans…

+
+ + +
+ + + + + + + + + + + + + + + + + +
OrderPlanMonthlyYearlyMonthly LimitOverage %ActiveActions
+
+ + +
+ +
+ + + + +
+ + +{% endblock %} diff --git a/frontend/templates/admin_users.html b/frontend/templates/admin_users.html index d23b04ad..84ed2042 100644 --- a/frontend/templates/admin_users.html +++ b/frontend/templates/admin_users.html @@ -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" > - - - + + +

Sets the quota limits for this user. Limits are enforced on upload.

+ +
+ + +
+ + +
+ + +

+ Annual carry-over calculates from this date. Leave blank for monthly enforcement. +

+
+ @@ -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; }, diff --git a/frontend/templates/base.html b/frontend/templates/base.html index ad1020ad..a6db72cb 100644 --- a/frontend/templates/base.html +++ b/frontend/templates/base.html @@ -99,6 +99,9 @@ Users + + Plan Designer + Credentials @@ -186,6 +189,9 @@ Users + + Plan Designer + Credentials diff --git a/frontend/templates/pricing.html b/frontend/templates/pricing.html index 03492152..685a0047 100644 --- a/frontend/templates/pricing.html +++ b/frontend/templates/pricing.html @@ -170,19 +170,6 @@ {% endfor %} - - Files per day - {% for tier in tiers %} - - {% if tier.daily_upload_limit == 0 %} - Unlimited - {% else %} - {{ tier.daily_upload_limit }} - {% endif %} - - {% endfor %} - - Files per month {% for tier in tiers %} diff --git a/migrations/versions/015_add_subscription_plans.py b/migrations/versions/015_add_subscription_plans.py new file mode 100644 index 00000000..5f8ce557 --- /dev/null +++ b/migrations/versions/015_add_subscription_plans.py @@ -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") diff --git a/migrations/versions/016_add_userprofile_billing.py b/migrations/versions/016_add_userprofile_billing.py new file mode 100644 index 00000000..60a8c7de --- /dev/null +++ b/migrations/versions/016_add_userprofile_billing.py @@ -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") diff --git a/tests/test_subscription.py b/tests/test_subscription.py index d0e2fce5..2f3a66c1 100644 --- a/tests/test_subscription.py +++ b/tests/test_subscription.py @@ -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) # ---------------------------------------------------------------------------