""" 구독·과금 시스템 — 플랜 관리 + 사용량 측정 + 청구서 생성 엔드포인트: GET /api/billing/plans — 플랜 목록 GET /api/billing/subscription — 현재 구독 정보 POST /api/billing/subscription — 구독 플랜 변경 GET /api/billing/usage — 이번 달 사용량 GET /api/billing/invoices — 청구서 목록 GET /api/billing/invoices/{id} — 청구서 상세 POST /api/billing/invoices/generate — 청구서 수동 생성 """ from __future__ import annotations import json import logging from datetime import date, datetime from typing import Optional from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user, require_admin_role from database import get_db from models import User, Server, SRRequest, Subscription, Invoice # 신규 logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/billing", tags=["Billing"]) PLANS = { "COMMUNITY": { "name": "커뮤니티", "price_monthly": 0, "max_servers": 20, "max_users": 10, "features": ["SR 관리", "CMDB 기본", "대시보드"], }, "STANDARD": { "name": "스탠다드", "price_monthly": 500000, "max_servers": 200, "max_users": 100, "features": ["COMMUNITY 포함", "AI 에이전트", "SLA 관리", "보고서"], }, "ENTERPRISE": { "name": "엔터프라이즈", "price_monthly": None, # 협의 "max_servers": -1, "max_users": -1, "features": ["STANDARD 포함", "무제한 서버", "FinOps", "전담 지원"], }, } class PlanChangeRequest(BaseModel): plan: str billing_cycle: str = "MONTHLY" # MONTHLY | YEARLY @router.get("/plans") async def list_plans(): return [ { "code": k, **v, "price_display": f"월 {v['price_monthly']:,}원" if v['price_monthly'] else "협의", } for k, v in PLANS.items() ] @router.get("/subscription") async def get_subscription( db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): row = await db.execute( select(Subscription).where( Subscription.tenant_id == user.tenant_id, Subscription.is_active == True, ) ) sub = row.scalar_one_or_none() if not sub: # 기본 COMMUNITY 플랜 반환 return { "plan": "COMMUNITY", "billing_cycle": "MONTHLY", "status": "ACTIVE", "price": 0, "next_billing": None, "is_trial": True, } plan_info = PLANS.get(sub.plan, {}) return { "plan": sub.plan, "plan_name": plan_info.get("name"), "billing_cycle": sub.billing_cycle, "status": sub.status, "price": plan_info.get("price_monthly", 0), "start_date": sub.start_date, "next_billing": sub.next_billing_date, "is_trial": sub.is_trial, } @router.post("/subscription") async def change_plan( req: PlanChangeRequest, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role), ): if req.plan not in PLANS: raise HTTPException(400, f"유효하지 않은 플랜: {req.plan}") row = await db.execute( select(Subscription).where(Subscription.tenant_id == user.tenant_id, Subscription.is_active == True) ) sub = row.scalar_one_or_none() if sub: sub.plan = req.plan sub.billing_cycle = req.billing_cycle sub.updated_at = datetime.utcnow() else: from datetime import timedelta sub = Subscription( tenant_id=user.tenant_id, plan=req.plan, billing_cycle=req.billing_cycle, status="ACTIVE", is_trial=(req.plan == "COMMUNITY"), start_date=date.today(), next_billing_date=date.today().replace(day=1) + timedelta(days=32), is_active=True, created_at=datetime.utcnow(), updated_at=datetime.utcnow(), ) db.add(sub) await db.commit() return {"ok": True, "plan": req.plan} @router.get("/usage") async def get_usage( db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): """이번 달 사용량 측정.""" month_start = date.today().replace(day=1) server_count = (await db.execute( select(func.count(Server.id)).where(Server.institution_id == user.tenant_id) )).scalar() or 0 user_count = (await db.execute( select(func.count(User.id)).where(User.tenant_id == user.tenant_id, User.is_active == True) )).scalar() or 0 sr_count = (await db.execute( select(func.count(SRRequest.id)).where(SRRequest.created_at >= month_start) )).scalar() or 0 # 현재 플랜 한도 sub = await get_subscription(db, user) plan_code = sub.get("plan", "COMMUNITY") plan = PLANS.get(plan_code, PLANS["COMMUNITY"]) return { "period": month_start.isoformat(), "servers": {"used": server_count, "limit": plan["max_servers"]}, "users": {"used": user_count, "limit": plan["max_users"]}, "sr_this_month": sr_count, "plan": plan_code, } @router.post("/invoices/generate") async def generate_invoice( db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role), ): """이번 달 청구서 수동 생성.""" usage = await get_usage(db, user) sub = await get_subscription(db, user) plan_info = PLANS.get(sub.get("plan", "COMMUNITY"), {}) price = plan_info.get("price_monthly", 0) or 0 invoice = Invoice( tenant_id=user.tenant_id, plan=sub.get("plan"), period=date.today().replace(day=1).isoformat(), amount=price, servers_used=usage["servers"]["used"], users_used=usage["users"]["used"], sr_count=usage["sr_this_month"], status="DRAFT", created_at=datetime.utcnow(), ) db.add(invoice) await db.commit() await db.refresh(invoice) return { "ok": True, "invoice_id": invoice.id, "amount": price, "period": invoice.period, "status": "DRAFT", } @router.get("/invoices") async def list_invoices( db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): rows = await db.execute( select(Invoice).where(Invoice.tenant_id == user.tenant_id) .order_by(Invoice.created_at.desc()).limit(24) ) invoices = rows.scalars().all() return [ {"id": i.id, "period": i.period, "amount": i.amount, "plan": i.plan, "status": i.status, "created_at": i.created_at} for i in invoices ]