fix: Restore all files lost during destructive rebase
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>
This commit is contained in:
554
backend/billing_client.py
Normal file
554
backend/billing_client.py
Normal file
@@ -0,0 +1,554 @@
|
||||
"""
|
||||
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()
|
||||
Reference in New Issue
Block a user