"""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/", 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/", 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/", 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/", 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/", 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//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//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", )