This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
breakpilot-pwa/backend/billing_client.py
Benjamin Admin bfdaf63ba9 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>
2026-02-09 09:51:32 +01:00

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()