Files
gh-christianlouis-docuelevate/app/api/plans.py
T
copilot-swe-agent[bot] 1a195a96bd fix: merge main, address code review feedback for security fix PR #816
- Merge origin/main into branch (resolve conflict in integrations_dashboard.html)
- Add defensive JSON parsing with try/except for integration.config
- Wrap tester() call in try/except to prevent 500 errors from bad config
- Add i18n key integrations.connection_test_failed_fallback in en.json
- Reference i18n key in template JS fallback message
- Update SECURITY_AUDIT.md: add fix date (2026-03-23), update doc date
- Remove accidental revert.sh file
- Fix missing MagicMock/patch imports in test file
- Add tests for invalid JSON config and tester exception error paths

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/daebb70e-059a-4601-8864-88eef49f99cf
2026-03-23 16:21:09 +00:00

289 lines
11 KiB
Python

"""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
stripe_price_id_monthly: str | None = None
stripe_price_id_yearly: str | None = None
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,
"stripe_price_id_monthly": plan.stripe_price_id_monthly,
"stripe_price_id_yearly": plan.stripe_price_id_yearly,
"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
plan.stripe_price_id_monthly = body.stripe_price_id_monthly or None
plan.stripe_price_id_yearly = body.stripe_price_id_yearly or None
# ---------------------------------------------------------------------------
# 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
# Fetch all requested plans in a single query to avoid N+1
plan_ids = body.order
plans = db.query(SubscriptionPlan).filter(SubscriptionPlan.plan_id.in_(plan_ids)).all()
# Build a map for fast O(1) lookup
plan_map = {p.plan_id: p for p in plans}
for sort_order, plan_id in enumerate(plan_ids):
plan = plan_map.get(plan_id)
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)