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