A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
555 lines
19 KiB
Python
555 lines
19 KiB
Python
"""
|
|
Billing Service Client fuer BreakPilot
|
|
Kommuniziert mit dem Billing Service fuer Subscription Management und Usage Tracking
|
|
"""
|
|
|
|
import httpx
|
|
from datetime import datetime
|
|
from typing import Optional, List, Dict, Any
|
|
from dataclasses import dataclass, field
|
|
from enum import Enum
|
|
import os
|
|
|
|
# Billing Service URL (aus Umgebungsvariable oder Standard fuer lokale Entwicklung)
|
|
BILLING_SERVICE_URL = os.getenv("BILLING_SERVICE_URL", "http://localhost:8083")
|
|
|
|
|
|
class PlanID(str, Enum):
|
|
"""Verfuegbare Plan-IDs"""
|
|
BASIC = "basic"
|
|
STANDARD = "standard"
|
|
PREMIUM = "premium"
|
|
|
|
|
|
class SubscriptionStatus(str, Enum):
|
|
"""Subscription Status Werte"""
|
|
TRIALING = "trialing"
|
|
ACTIVE = "active"
|
|
PAST_DUE = "past_due"
|
|
CANCELED = "canceled"
|
|
EXPIRED = "expired"
|
|
|
|
|
|
class TaskType(str, Enum):
|
|
"""Task-Typen fuer Usage Tracking"""
|
|
CORRECTION = "correction"
|
|
LETTER = "letter"
|
|
MEETING = "meeting"
|
|
BATCH = "batch"
|
|
OTHER = "other"
|
|
|
|
|
|
@dataclass
|
|
class PlanFeatures:
|
|
"""Features eines Billing Plans"""
|
|
monthly_task_allowance: int = 0
|
|
max_task_balance: int = 0
|
|
feature_flags: List[str] = field(default_factory=list)
|
|
max_team_members: int = 1
|
|
priority_support: bool = False
|
|
custom_branding: bool = False
|
|
batch_processing: bool = False
|
|
custom_templates: bool = False
|
|
fair_use_mode: bool = False
|
|
|
|
|
|
@dataclass
|
|
class BillingPlan:
|
|
"""Billing Plan Informationen"""
|
|
id: PlanID
|
|
name: str
|
|
description: str
|
|
price_cents: int
|
|
currency: str
|
|
interval: str
|
|
features: PlanFeatures
|
|
is_active: bool = True
|
|
sort_order: int = 0
|
|
|
|
|
|
@dataclass
|
|
class SubscriptionInfo:
|
|
"""Subscription Details"""
|
|
plan_id: PlanID
|
|
plan_name: str
|
|
status: SubscriptionStatus
|
|
is_trialing: bool
|
|
cancel_at_period_end: bool
|
|
price_cents: int
|
|
currency: str
|
|
trial_days_left: int = 0
|
|
current_period_end: Optional[str] = None
|
|
|
|
|
|
@dataclass
|
|
class TaskUsageInfo:
|
|
"""Task-basierte Usage Informationen"""
|
|
tasks_available: int
|
|
max_tasks: int
|
|
info_text: str
|
|
tooltip_text: str
|
|
|
|
|
|
@dataclass
|
|
class EntitlementInfo:
|
|
"""Feature Entitlements"""
|
|
features: List[str]
|
|
max_team_members: int = 1
|
|
priority_support: bool = False
|
|
custom_branding: bool = False
|
|
batch_processing: bool = False
|
|
custom_templates: bool = False
|
|
fair_use_mode: bool = False
|
|
|
|
|
|
@dataclass
|
|
class BillingStatus:
|
|
"""Vollstaendiger Billing Status"""
|
|
has_subscription: bool
|
|
subscription: Optional[SubscriptionInfo] = None
|
|
task_usage: Optional[TaskUsageInfo] = None
|
|
entitlements: Optional[EntitlementInfo] = None
|
|
available_plans: List[BillingPlan] = field(default_factory=list)
|
|
|
|
|
|
@dataclass
|
|
class CheckoutSession:
|
|
"""Stripe Checkout Session"""
|
|
checkout_url: str
|
|
session_id: str
|
|
|
|
|
|
@dataclass
|
|
class UsageCheckResult:
|
|
"""Ergebnis einer Usage-Pruefung"""
|
|
allowed: bool
|
|
current_usage: int
|
|
limit: int
|
|
remaining: int
|
|
message: Optional[str] = None
|
|
|
|
|
|
@dataclass
|
|
class EntitlementCheckResult:
|
|
"""Ergebnis einer Entitlement-Pruefung"""
|
|
has_entitlement: bool
|
|
plan_id: Optional[PlanID] = None
|
|
message: Optional[str] = None
|
|
|
|
|
|
class BillingClient:
|
|
"""Client fuer die Kommunikation mit dem Billing Service"""
|
|
|
|
def __init__(self, base_url: str = BILLING_SERVICE_URL):
|
|
self.base_url = base_url.rstrip("/")
|
|
self.api_url = f"{self.base_url}/api/v1/billing"
|
|
|
|
def _get_headers(self, jwt_token: str) -> Dict[str, str]:
|
|
"""Erstellt die Header mit JWT Token"""
|
|
return {
|
|
"Authorization": f"Bearer {jwt_token}",
|
|
"Content-Type": "application/json"
|
|
}
|
|
|
|
def _parse_plan_features(self, data: Dict[str, Any]) -> PlanFeatures:
|
|
"""Parst PlanFeatures aus JSON"""
|
|
return PlanFeatures(
|
|
monthly_task_allowance=data.get("monthly_task_allowance", 0),
|
|
max_task_balance=data.get("max_task_balance", 0),
|
|
feature_flags=data.get("feature_flags", []),
|
|
max_team_members=data.get("max_team_members", 1),
|
|
priority_support=data.get("priority_support", False),
|
|
custom_branding=data.get("custom_branding", False),
|
|
batch_processing=data.get("batch_processing", False),
|
|
custom_templates=data.get("custom_templates", False),
|
|
fair_use_mode=data.get("fair_use_mode", False),
|
|
)
|
|
|
|
def _parse_plan(self, data: Dict[str, Any]) -> BillingPlan:
|
|
"""Parst BillingPlan aus JSON"""
|
|
return BillingPlan(
|
|
id=PlanID(data["id"]),
|
|
name=data["name"],
|
|
description=data.get("description", ""),
|
|
price_cents=data.get("price_cents", 0),
|
|
currency=data.get("currency", "eur"),
|
|
interval=data.get("interval", "month"),
|
|
features=self._parse_plan_features(data.get("features", {})),
|
|
is_active=data.get("is_active", True),
|
|
sort_order=data.get("sort_order", 0),
|
|
)
|
|
|
|
# =============================================
|
|
# User Endpoints (Auth required)
|
|
# =============================================
|
|
|
|
async def get_billing_status(self, jwt_token: str) -> BillingStatus:
|
|
"""
|
|
Holt den aktuellen Billing Status inkl. Subscription, Usage und Entitlements.
|
|
GET /api/v1/billing/status
|
|
"""
|
|
async with httpx.AsyncClient() as client:
|
|
try:
|
|
response = await client.get(
|
|
f"{self.api_url}/status",
|
|
headers=self._get_headers(jwt_token),
|
|
timeout=10.0
|
|
)
|
|
|
|
if response.status_code == 200:
|
|
data = response.json()
|
|
|
|
subscription = None
|
|
if data.get("subscription"):
|
|
sub = data["subscription"]
|
|
subscription = SubscriptionInfo(
|
|
plan_id=PlanID(sub["plan_id"]),
|
|
plan_name=sub.get("plan_name", ""),
|
|
status=SubscriptionStatus(sub["status"]),
|
|
is_trialing=sub.get("is_trialing", False),
|
|
cancel_at_period_end=sub.get("cancel_at_period_end", False),
|
|
price_cents=sub.get("price_cents", 0),
|
|
currency=sub.get("currency", "eur"),
|
|
trial_days_left=sub.get("trial_days_left", 0),
|
|
current_period_end=sub.get("current_period_end"),
|
|
)
|
|
|
|
task_usage = None
|
|
if data.get("task_usage"):
|
|
tu = data["task_usage"]
|
|
task_usage = TaskUsageInfo(
|
|
tasks_available=tu.get("tasks_available", 0),
|
|
max_tasks=tu.get("max_tasks", 0),
|
|
info_text=tu.get("info_text", ""),
|
|
tooltip_text=tu.get("tooltip_text", ""),
|
|
)
|
|
|
|
entitlements = None
|
|
if data.get("entitlements"):
|
|
ent = data["entitlements"]
|
|
entitlements = EntitlementInfo(
|
|
features=ent.get("features", []),
|
|
max_team_members=ent.get("max_team_members", 1),
|
|
priority_support=ent.get("priority_support", False),
|
|
custom_branding=ent.get("custom_branding", False),
|
|
batch_processing=ent.get("batch_processing", False),
|
|
custom_templates=ent.get("custom_templates", False),
|
|
fair_use_mode=ent.get("fair_use_mode", False),
|
|
)
|
|
|
|
plans = []
|
|
for p in data.get("available_plans", []):
|
|
plans.append(self._parse_plan(p))
|
|
|
|
return BillingStatus(
|
|
has_subscription=data.get("has_subscription", False),
|
|
subscription=subscription,
|
|
task_usage=task_usage,
|
|
entitlements=entitlements,
|
|
available_plans=plans,
|
|
)
|
|
else:
|
|
return BillingStatus(has_subscription=False)
|
|
|
|
except httpx.RequestError:
|
|
# Bei Verbindungsproblemen: Leeren Status zurueckgeben
|
|
return BillingStatus(has_subscription=False)
|
|
|
|
async def get_plans(self, jwt_token: str) -> List[BillingPlan]:
|
|
"""
|
|
Holt alle verfuegbaren Billing Plans.
|
|
GET /api/v1/billing/plans
|
|
"""
|
|
async with httpx.AsyncClient() as client:
|
|
try:
|
|
response = await client.get(
|
|
f"{self.api_url}/plans",
|
|
headers=self._get_headers(jwt_token),
|
|
timeout=10.0
|
|
)
|
|
|
|
if response.status_code == 200:
|
|
data = response.json()
|
|
return [self._parse_plan(p) for p in data.get("plans", [])]
|
|
return []
|
|
|
|
except httpx.RequestError:
|
|
return []
|
|
|
|
async def start_trial(
|
|
self,
|
|
jwt_token: str,
|
|
plan_id: PlanID
|
|
) -> Optional[CheckoutSession]:
|
|
"""
|
|
Startet einen Trial fuer den angegebenen Plan.
|
|
Gibt eine Stripe Checkout URL zurueck.
|
|
POST /api/v1/billing/trial/start
|
|
"""
|
|
async with httpx.AsyncClient() as client:
|
|
try:
|
|
response = await client.post(
|
|
f"{self.api_url}/trial/start",
|
|
headers=self._get_headers(jwt_token),
|
|
json={"plan_id": plan_id.value},
|
|
timeout=10.0
|
|
)
|
|
|
|
if response.status_code == 200:
|
|
data = response.json()
|
|
return CheckoutSession(
|
|
checkout_url=data["checkout_url"],
|
|
session_id=data["session_id"],
|
|
)
|
|
return None
|
|
|
|
except httpx.RequestError:
|
|
return None
|
|
|
|
async def change_plan(
|
|
self,
|
|
jwt_token: str,
|
|
new_plan_id: PlanID
|
|
) -> bool:
|
|
"""
|
|
Wechselt zu einem anderen Plan.
|
|
POST /api/v1/billing/change-plan
|
|
"""
|
|
async with httpx.AsyncClient() as client:
|
|
try:
|
|
response = await client.post(
|
|
f"{self.api_url}/change-plan",
|
|
headers=self._get_headers(jwt_token),
|
|
json={"new_plan_id": new_plan_id.value},
|
|
timeout=10.0
|
|
)
|
|
return response.status_code == 200
|
|
|
|
except httpx.RequestError:
|
|
return False
|
|
|
|
async def cancel_subscription(self, jwt_token: str) -> bool:
|
|
"""
|
|
Kuendigt das Abo zum Periodenende.
|
|
POST /api/v1/billing/cancel
|
|
"""
|
|
async with httpx.AsyncClient() as client:
|
|
try:
|
|
response = await client.post(
|
|
f"{self.api_url}/cancel",
|
|
headers=self._get_headers(jwt_token),
|
|
timeout=10.0
|
|
)
|
|
return response.status_code == 200
|
|
|
|
except httpx.RequestError:
|
|
return False
|
|
|
|
async def get_customer_portal_url(self, jwt_token: str) -> Optional[str]:
|
|
"""
|
|
Holt die Stripe Customer Portal URL.
|
|
GET /api/v1/billing/portal
|
|
"""
|
|
async with httpx.AsyncClient() as client:
|
|
try:
|
|
response = await client.get(
|
|
f"{self.api_url}/portal",
|
|
headers=self._get_headers(jwt_token),
|
|
timeout=10.0
|
|
)
|
|
|
|
if response.status_code == 200:
|
|
return response.json().get("portal_url")
|
|
return None
|
|
|
|
except httpx.RequestError:
|
|
return None
|
|
|
|
# =============================================
|
|
# Internal Endpoints (Service-to-Service)
|
|
# =============================================
|
|
|
|
async def get_entitlements(self, user_id: str) -> Optional[EntitlementInfo]:
|
|
"""
|
|
Holt die Entitlements fuer einen User (intern).
|
|
GET /api/v1/billing/entitlements/:userId
|
|
"""
|
|
async with httpx.AsyncClient() as client:
|
|
try:
|
|
response = await client.get(
|
|
f"{self.api_url}/entitlements/{user_id}",
|
|
timeout=10.0
|
|
)
|
|
|
|
if response.status_code == 200:
|
|
data = response.json()
|
|
return EntitlementInfo(
|
|
features=data.get("features", {}).get("feature_flags", []),
|
|
max_team_members=data.get("features", {}).get("max_team_members", 1),
|
|
priority_support=data.get("features", {}).get("priority_support", False),
|
|
custom_branding=data.get("features", {}).get("custom_branding", False),
|
|
batch_processing=data.get("features", {}).get("batch_processing", False),
|
|
custom_templates=data.get("features", {}).get("custom_templates", False),
|
|
fair_use_mode=data.get("features", {}).get("fair_use_mode", False),
|
|
)
|
|
return None
|
|
|
|
except httpx.RequestError:
|
|
return None
|
|
|
|
async def check_entitlement(
|
|
self,
|
|
user_id: str,
|
|
feature: str
|
|
) -> EntitlementCheckResult:
|
|
"""
|
|
Prueft ob ein User ein bestimmtes Feature hat (intern).
|
|
GET /api/v1/billing/entitlements/check/:userId/:feature
|
|
"""
|
|
async with httpx.AsyncClient() as client:
|
|
try:
|
|
response = await client.get(
|
|
f"{self.api_url}/entitlements/check/{user_id}/{feature}",
|
|
timeout=10.0
|
|
)
|
|
|
|
if response.status_code == 200:
|
|
data = response.json()
|
|
return EntitlementCheckResult(
|
|
has_entitlement=data.get("has_entitlement", False),
|
|
plan_id=PlanID(data["plan_id"]) if data.get("plan_id") else None,
|
|
message=data.get("message"),
|
|
)
|
|
return EntitlementCheckResult(has_entitlement=False)
|
|
|
|
except httpx.RequestError:
|
|
# Bei Fehler: Zugriff erlauben (fail open)
|
|
return EntitlementCheckResult(has_entitlement=True)
|
|
|
|
async def track_usage(
|
|
self,
|
|
user_id: str,
|
|
usage_type: str,
|
|
quantity: int = 1
|
|
) -> bool:
|
|
"""
|
|
Trackt Usage fuer einen User (intern).
|
|
POST /api/v1/billing/usage/track
|
|
"""
|
|
async with httpx.AsyncClient() as client:
|
|
try:
|
|
response = await client.post(
|
|
f"{self.api_url}/usage/track",
|
|
json={
|
|
"user_id": user_id,
|
|
"usage_type": usage_type,
|
|
"quantity": quantity,
|
|
},
|
|
timeout=10.0
|
|
)
|
|
return response.status_code == 200
|
|
|
|
except httpx.RequestError:
|
|
return False
|
|
|
|
async def check_usage(
|
|
self,
|
|
user_id: str,
|
|
usage_type: str
|
|
) -> UsageCheckResult:
|
|
"""
|
|
Prueft ob Usage erlaubt ist (intern).
|
|
GET /api/v1/billing/usage/check/:userId/:type
|
|
"""
|
|
async with httpx.AsyncClient() as client:
|
|
try:
|
|
response = await client.get(
|
|
f"{self.api_url}/usage/check/{user_id}/{usage_type}",
|
|
timeout=10.0
|
|
)
|
|
|
|
if response.status_code == 200:
|
|
data = response.json()
|
|
return UsageCheckResult(
|
|
allowed=data.get("allowed", False),
|
|
current_usage=data.get("current_usage", 0),
|
|
limit=data.get("limit", 0),
|
|
remaining=data.get("remaining", 0),
|
|
message=data.get("message"),
|
|
)
|
|
return UsageCheckResult(
|
|
allowed=False,
|
|
current_usage=0,
|
|
limit=0,
|
|
remaining=0,
|
|
message="Failed to check usage",
|
|
)
|
|
|
|
except httpx.RequestError:
|
|
# Bei Fehler: Zugriff erlauben (fail open)
|
|
return UsageCheckResult(
|
|
allowed=True,
|
|
current_usage=0,
|
|
limit=9999,
|
|
remaining=9999,
|
|
)
|
|
|
|
# =============================================
|
|
# Convenience Methods
|
|
# =============================================
|
|
|
|
async def has_active_subscription(self, jwt_token: str) -> bool:
|
|
"""Prueft ob der User ein aktives Abo hat"""
|
|
status = await self.get_billing_status(jwt_token)
|
|
if not status.has_subscription or not status.subscription:
|
|
return False
|
|
return status.subscription.status in [
|
|
SubscriptionStatus.ACTIVE,
|
|
SubscriptionStatus.TRIALING
|
|
]
|
|
|
|
async def has_feature(self, jwt_token: str, feature: str) -> bool:
|
|
"""Prueft ob der User ein bestimmtes Feature hat"""
|
|
status = await self.get_billing_status(jwt_token)
|
|
if not status.entitlements:
|
|
return False
|
|
return feature in status.entitlements.features
|
|
|
|
async def can_use_batch_processing(self, jwt_token: str) -> bool:
|
|
"""Prueft ob der User Batch-Verarbeitung nutzen kann"""
|
|
status = await self.get_billing_status(jwt_token)
|
|
if not status.entitlements:
|
|
return False
|
|
return status.entitlements.batch_processing
|
|
|
|
async def can_use_custom_templates(self, jwt_token: str) -> bool:
|
|
"""Prueft ob der User Custom Templates nutzen kann"""
|
|
status = await self.get_billing_status(jwt_token)
|
|
if not status.entitlements:
|
|
return False
|
|
return status.entitlements.custom_templates
|
|
|
|
async def get_tasks_available(self, jwt_token: str) -> int:
|
|
"""Gibt die Anzahl verfuegbarer Tasks zurueck"""
|
|
status = await self.get_billing_status(jwt_token)
|
|
if not status.task_usage:
|
|
return 0
|
|
return status.task_usage.tasks_available
|
|
|
|
async def health_check(self) -> bool:
|
|
"""Prueft ob der Billing Service erreichbar ist"""
|
|
async with httpx.AsyncClient() as client:
|
|
try:
|
|
response = await client.get(
|
|
f"{self.base_url}/health",
|
|
timeout=5.0
|
|
)
|
|
return response.status_code == 200
|
|
|
|
except httpx.RequestError:
|
|
return False
|
|
|
|
|
|
# Singleton-Instanz fuer einfachen Zugriff
|
|
billing_client = BillingClient()
|