569 lines
20 KiB
Python
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",
|
|
)
|