zioinfo-mail/workspace/guardia-itsm/routers/billing.py
DESKTOP-TKLFCPR\ython fc0ba65e05 feat(expansion): GUARDiA v3 P3 완성 — 13 routers + 14 DB tables
라우터 (667개 엔드포인트, P3 신규 69개):
- multimodal.py:      llava 이미지 분석 + 에러 자동 분류
- learning_loop.py:   Ollama 파인튜닝 + 품질 지표
- ai_insights.py:     주간 인사이트 + 반복 패턴 + 개선 권고
- container_alerts.py: Docker 이상 감지 → SR 자동 생성
- ncloud.py:          NCloud API (서버/LB/스토리지/비용)
- billing.py:         구독 플랜 + 사용량 측정 + 청구서
- servicenow.py:      ServiceNow CMDB/Incident 양방향 연동
- erp_connector.py:   그룹웨어/HR ERP 연동 + 결재 웹훅
- kakao_notify.py:    카카오 알림톡 + 대량 발송
- auto_report.py:     Excel/PDF 보고서 자동 생성·다운로드
- benchmark.py:       기관 간 익명 벤치마킹 (완전 익명화)
- cohort_analysis.py: 도입 코호트 + 리텐션 + 기능 도입률

DB 모델 (14개 신규 테이블):
tb_learning_run, tb_container_alert_{rule,log},
tb_ncloud_config, tb_subscription, tb_invoice,
tb_servicenow_{config,mapping}, tb_erp_config,
tb_kakao_{config,notify_log}, tb_report_{record,schedule},
tb_benchmark_contrib

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 06:06:59 +09:00

212 lines
6.6 KiB
Python

"""
구독·과금 시스템 — 플랜 관리 + 사용량 측정 + 청구서 생성
엔드포인트:
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
]