Files
gatehouse-api/gatehouse_app/api/v1/superadmin/billing.py
T

569 lines
20 KiB
Python

"""Superadmin billing endpoints for plans and subscriptions."""
import logging
from datetime import datetime, timezone
from flask import request
from gatehouse_app.api.v1.superadmin import superadmin_bp
from gatehouse_app.utils.response import api_response
from gatehouse_app.decorators.superadmin import superadmin_required, superadmin_audit_log
from gatehouse_app.models.organization.organization import Organization
from gatehouse_app.extensions import db
logger = logging.getLogger(__name__)
# ============ Plans Endpoints ============
@superadmin_bp.route("/billing/plans", methods=["GET"])
@superadmin_required
def list_plans():
"""Get all available plans."""
try:
from gatehouse_app.models.billing.plan import Plan
plans = Plan.query.filter(Plan.is_active == True).order_by(Plan.price_monthly.asc()).all()
items = [{
"id": p.id,
"name": p.name,
"slug": p.slug,
"description": p.description,
"price_monthly": p.price_monthly,
"price_yearly": p.price_yearly,
"included_users": p.included_users,
"overage_rate_per_user": p.overage_rate_per_user,
"features": p.features,
"stripe_price_id_monthly": p.stripe_price_id_monthly,
"stripe_price_id_yearly": p.stripe_price_id_yearly,
"is_active": p.is_active,
"created_at": p.created_at.isoformat() + "Z" if p.created_at else None,
} for p in plans]
return api_response(data={"items": items}, message="Plans retrieved successfully")
except Exception as e:
logger.error(f"[Billing] List plans error: {e}")
return api_response(
success=False,
message="An error occurred",
status=500,
error_type="INTERNAL_ERROR",
)
@superadmin_bp.route("/billing/plans", methods=["POST"])
@superadmin_required
@superadmin_audit_log(action="plan.create", resource_type="plan")
def create_plan():
"""Create a new plan."""
try:
from gatehouse_app.models.billing.plan import Plan
from marshmallow import Schema, fields, validate
class CreatePlanSchema(Schema):
name = fields.Str(required=True, validate=validate.Length(min=1, max=100))
slug = fields.Str(required=True, validate=validate.Length(min=1, max=50))
description = fields.Str(allow_none=True)
price_monthly = fields.Int(required=True, validate=validate.Range(min=0))
price_yearly = fields.Int(required=True, validate=validate.Range(min=0))
included_users = fields.Int(required=True, validate=validate.Range(min=0))
overage_rate_per_user = fields.Int(required=True, validate=validate.Range(min=0))
features = fields.Dict(allow_none=True)
stripe_price_id_monthly = fields.Str(allow_none=True)
stripe_price_id_yearly = fields.Str(allow_none=True)
data = request.json or {}
schema = CreatePlanSchema()
errors = schema.validate(data)
if errors:
return api_response(
success=False,
message="Validation error",
status=400,
error_type="VALIDATION_ERROR",
error_details=errors,
)
# Check if slug already exists
existing = Plan.query.filter_by(slug=data["slug"]).first()
if existing:
return api_response(
success=False,
message="Plan with this slug already exists",
status=400,
error_type="VALIDATION_ERROR",
)
plan = Plan(
name=data["name"],
slug=data["slug"],
description=data.get("description"),
price_monthly=data["price_monthly"],
price_yearly=data["price_yearly"],
included_users=data["included_users"],
overage_rate_per_user=data["overage_rate_per_user"],
features=data.get("features"),
stripe_price_id_monthly=data.get("stripe_price_id_monthly"),
stripe_price_id_yearly=data.get("stripe_price_id_yearly"),
)
db.session.add(plan)
db.session.commit()
return api_response(data={
"id": plan.id,
"name": plan.name,
"slug": plan.slug,
}, message="Plan created successfully", status=201)
except Exception as e:
db.session.rollback()
logger.error(f"[Billing] Create plan error: {e}")
return api_response(
success=False,
message="An error occurred",
status=500,
error_type="INTERNAL_ERROR",
)
@superadmin_bp.route("/billing/plans/<plan_id>", methods=["GET"])
@superadmin_required
def get_plan(plan_id):
"""Get a single plan by ID."""
try:
from gatehouse_app.models.billing.plan import Plan
plan = Plan.query.get(plan_id)
if not plan:
return api_response(
success=False,
message="Plan not found",
status=404,
error_type="NOT_FOUND",
)
return api_response(data={
"id": plan.id,
"name": plan.name,
"slug": plan.slug,
"description": plan.description,
"price_monthly": plan.price_monthly,
"price_yearly": plan.price_yearly,
"included_users": plan.included_users,
"overage_rate_per_user": plan.overage_rate_per_user,
"features": plan.features,
"stripe_price_id_monthly": plan.stripe_price_id_monthly,
"stripe_price_id_yearly": plan.stripe_price_id_yearly,
"is_active": plan.is_active,
"created_at": plan.created_at.isoformat() + "Z" if plan.created_at else None,
"updated_at": plan.updated_at.isoformat() + "Z" if plan.updated_at else None,
}, message="Plan retrieved successfully")
except Exception as e:
logger.error(f"[Billing] Get plan error: {e}")
return api_response(
success=False,
message="An error occurred",
status=500,
error_type="INTERNAL_ERROR",
)
@superadmin_bp.route("/billing/plans/<plan_id>", methods=["PATCH"])
@superadmin_required
@superadmin_audit_log(action="plan.update", resource_type="plan")
def update_plan(plan_id):
"""Update a plan."""
try:
from gatehouse_app.models.billing.plan import Plan
from marshmallow import Schema, fields, validate
class UpdatePlanSchema(Schema):
name = fields.Str(validate=validate.Length(min=1, max=100))
description = fields.Str(allow_none=True)
price_monthly = fields.Int(validate=validate.Range(min=0))
price_yearly = fields.Int(validate=validate.Range(min=0))
included_users = fields.Int(validate=validate.Range(min=0))
overage_rate_per_user = fields.Int(validate=validate.Range(min=0))
features = fields.Dict(allow_none=True)
stripe_price_id_monthly = fields.Str(allow_none=True)
stripe_price_id_yearly = fields.Str(allow_none=True)
is_active = fields.Bool()
plan = Plan.query.get(plan_id)
if not plan:
return api_response(
success=False,
message="Plan not found",
status=404,
error_type="NOT_FOUND",
)
data = request.json or {}
schema = UpdatePlanSchema()
errors = schema.validate(data)
if errors:
return api_response(
success=False,
message="Validation error",
status=400,
error_type="VALIDATION_ERROR",
error_details=errors,
)
# Update fields
for key, value in data.items():
if hasattr(plan, key):
setattr(plan, key, value)
db.session.commit()
return api_response(data={
"id": plan.id,
"name": plan.name,
"slug": plan.slug,
}, message="Plan updated successfully")
except Exception as e:
db.session.rollback()
logger.error(f"[Billing] Update plan error: {e}")
return api_response(
success=False,
message="An error occurred",
status=500,
error_type="INTERNAL_ERROR",
)
@superadmin_bp.route("/billing/plans/<plan_id>", methods=["DELETE"])
@superadmin_required
@superadmin_audit_log(action="plan.delete", resource_type="plan")
def delete_plan(plan_id):
"""Soft-delete a plan by setting is_active=False."""
try:
from gatehouse_app.models.billing.plan import Plan
plan = Plan.query.get(plan_id)
if not plan:
return api_response(
success=False,
message="Plan not found",
status=404,
error_type="NOT_FOUND",
)
plan.is_active = False
db.session.commit()
return api_response(data={"id": plan.id}, message="Plan deleted successfully")
except Exception as e:
db.session.rollback()
logger.error(f"[Billing] Delete plan error: {e}")
return api_response(
success=False,
message="An error occurred",
status=500,
error_type="INTERNAL_ERROR",
)
# ============ Subscriptions Endpoints ============
@superadmin_bp.route("/billing/subscriptions", methods=["GET"])
@superadmin_required
def list_subscriptions():
"""Get all subscriptions with optional filters."""
try:
from gatehouse_app.models.billing.subscription import Subscription
from gatehouse_app.models.billing.plan import Plan
page = max(1, int(request.args.get("page", 1)))
per_page = min(100, max(1, int(request.args.get("per_page", 20))))
plan_id = request.args.get("plan_id")
status = request.args.get("status")
query = Subscription.query
if plan_id:
query = query.filter(Subscription.plan_id == plan_id)
if status:
query = query.filter(Subscription.status == status)
total = query.count()
subs = query.order_by(Subscription.created_at.desc()).offset((page - 1) * per_page).limit(per_page).all()
items = []
for sub in subs:
org = Organization.query.get(sub.organization_id)
plan = Plan.query.get(sub.plan_id) if sub.plan_id else None
# Calculate MRR
if plan and sub.status == "active":
mrr = plan.price_monthly if sub.billing_cycle == "monthly" else plan.price_yearly // 12
else:
mrr = 0
items.append({
"id": sub.id,
"organization_id": sub.organization_id,
"org_name": org.name if org else "Unknown",
"plan_id": sub.plan_id,
"plan_name": plan.name if plan else "Unknown",
"status": sub.status,
"billing_cycle": sub.billing_cycle,
"mrr": mrr,
"current_period_start": sub.current_period_start.isoformat() + "Z" if sub.current_period_start else None,
"current_period_end": sub.current_period_end.isoformat() + "Z" if sub.current_period_end else None,
"trial_ends_at": sub.trial_ends_at.isoformat() + "Z" if sub.trial_ends_at else None,
"cancel_at_period_end": sub.cancel_at_period_end,
"created_at": sub.created_at.isoformat() + "Z" if sub.created_at else None,
})
return api_response(data={
"items": items,
"total": total,
"page": page,
"per_page": per_page,
"pages": (total + per_page - 1) // per_page if per_page > 0 else 0,
}, message="Subscriptions retrieved successfully")
except Exception as e:
logger.error(f"[Billing] List subscriptions error: {e}")
return api_response(
success=False,
message="An error occurred",
status=500,
error_type="INTERNAL_ERROR",
)
@superadmin_bp.route("/billing/subscriptions/<sub_id>", methods=["GET"])
@superadmin_required
def get_subscription(sub_id):
"""Get a single subscription."""
try:
from gatehouse_app.models.billing.subscription import Subscription
from gatehouse_app.models.billing.plan import Plan
sub = Subscription.query.get(sub_id)
if not sub:
return api_response(
success=False,
message="Subscription not found",
status=404,
error_type="NOT_FOUND",
)
org = Organization.query.get(sub.organization_id)
plan = Plan.query.get(sub.plan_id) if sub.plan_id else None
return api_response(data={
"id": sub.id,
"organization_id": sub.organization_id,
"org_name": org.name if org else "Unknown",
"plan_id": sub.plan_id,
"plan_name": plan.name if plan else None,
"status": sub.status,
"billing_cycle": sub.billing_cycle,
"current_period_start": sub.current_period_start.isoformat() + "Z" if sub.current_period_start else None,
"current_period_end": sub.current_period_end.isoformat() + "Z" if sub.current_period_end else None,
"trial_ends_at": sub.trial_ends_at.isoformat() + "Z" if sub.trial_ends_at else None,
"stripe_subscription_id": sub.stripe_subscription_id,
"overage_enabled": sub.overage_enabled,
"cancelled_at": sub.cancelled_at.isoformat() + "Z" if sub.cancelled_at else None,
"cancel_at_period_end": sub.cancel_at_period_end,
}, message="Subscription retrieved successfully")
except Exception as e:
logger.error(f"[Billing] Get subscription error: {e}")
return api_response(
success=False,
message="An error occurred",
status=500,
error_type="INTERNAL_ERROR",
)
@superadmin_bp.route("/billing/subscriptions/<org_id>", methods=["PATCH"])
@superadmin_required
@superadmin_audit_log(action="subscription.update", resource_type="subscription")
def update_subscription(org_id):
"""Update subscription plan or billing cycle for an organization."""
try:
from gatehouse_app.models.billing.subscription import Subscription
from gatehouse_app.models.billing.plan import Plan
org = Organization.query.get(org_id)
if not org:
return api_response(
success=False,
message="Organization not found",
status=404,
error_type="NOT_FOUND",
)
sub = Subscription.query.filter_by(organization_id=org_id).first()
if not sub:
return api_response(
success=False,
message="No subscription found for this organization",
status=404,
error_type="NOT_FOUND",
)
data = request.json or {}
if "plan_id" in data:
plan = Plan.query.get(data["plan_id"])
if not plan:
return api_response(
success=False,
message="Plan not found",
status=404,
error_type="NOT_FOUND",
)
sub.plan_id = data["plan_id"]
if "billing_cycle" in data:
if data["billing_cycle"] not in ["monthly", "yearly"]:
return api_response(
success=False,
message="Invalid billing cycle. Must be 'monthly' or 'yearly'",
status=400,
error_type="VALIDATION_ERROR",
)
sub.billing_cycle = data["billing_cycle"]
db.session.commit()
return api_response(data={
"id": sub.id,
"organization_id": sub.organization_id,
"plan_id": sub.plan_id,
"billing_cycle": sub.billing_cycle,
}, message="Subscription updated successfully")
except Exception as e:
db.session.rollback()
logger.error(f"[Billing] Update subscription error: {e}")
return api_response(
success=False,
message="An error occurred",
status=500,
error_type="INTERNAL_ERROR",
)
@superadmin_bp.route("/billing/subscriptions/<org_id>/cancel", methods=["POST"])
@superadmin_required
@superadmin_audit_log(action="subscription.cancel", resource_type="subscription")
def cancel_subscription(org_id):
"""Cancel subscription at period end."""
try:
from gatehouse_app.models.billing.subscription import Subscription
org = Organization.query.get(org_id)
if not org:
return api_response(
success=False,
message="Organization not found",
status=404,
error_type="NOT_FOUND",
)
sub = Subscription.query.filter_by(organization_id=org_id).first()
if not sub:
return api_response(
success=False,
message="No subscription found for this organization",
status=404,
error_type="NOT_FOUND",
)
sub.cancel_at_period_end = True
sub.status = "cancelled"
db.session.commit()
return api_response(data={
"id": sub.id,
"cancel_at_period_end": True,
"status": sub.status,
}, message="Subscription will be cancelled at period end")
except Exception as e:
db.session.rollback()
logger.error(f"[Billing] Cancel subscription error: {e}")
return api_response(
success=False,
message="An error occurred",
status=500,
error_type="INTERNAL_ERROR",
)
@superadmin_bp.route("/billing/subscriptions/<org_id>/trial", methods=["POST"])
@superadmin_required
@superadmin_audit_log(action="subscription.extend_trial", resource_type="subscription")
def extend_trial(org_id):
"""Extend trial period for an organization."""
try:
from gatehouse_app.models.billing.subscription import Subscription
from datetime import timedelta
data = request.json or {}
days = data.get("days", 30)
if not isinstance(days, int) or days < 1:
return api_response(
success=False,
message="Days must be a positive integer",
status=400,
error_type="VALIDATION_ERROR",
)
org = Organization.query.get(org_id)
if not org:
return api_response(
success=False,
message="Organization not found",
status=404,
error_type="NOT_FOUND",
)
sub = Subscription.query.filter_by(organization_id=org_id).first()
if not sub:
return api_response(
success=False,
message="No subscription found for this organization",
status=404,
error_type="NOT_FOUND",
)
# Extend trial
if sub.trial_ends_at:
sub.trial_ends_at = sub.trial_ends_at + timedelta(days=days)
else:
sub.trial_ends_at = datetime.now(timezone.utc) + timedelta(days=days)
sub.status = "trial"
db.session.commit()
return api_response(data={
"id": sub.id,
"trial_ends_at": sub.trial_ends_at.isoformat() + "Z",
"days_added": days,
}, message=f"Trial extended by {days} days")
except Exception as e:
db.session.rollback()
logger.error(f"[Billing] Extend trial error: {e}")
return api_response(
success=False,
message="An error occurred",
status=500,
error_type="INTERNAL_ERROR",
)