# Copyright (c) 2025 Patrick Motsch # All rights reserved. """ Billing Service - Central service for billing operations. Handles: - Balance checks before AI operations - Cost recording after AI operations - Provider permission checks via RBAC - Price calculation with markup """ import logging from typing import Dict, Any, List, Optional from datetime import datetime from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelBilling import ( BillingCheckResult, TransactionTypeEnum, ReferenceTypeEnum, BillingTransaction, BillingBalanceResponse, ) from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface logger = logging.getLogger(__name__) # Markup percentage for internal pricing (+50% für Infrastruktur und Platform Service + 50% für Währungsrisiko ==> Faktor 2.0) BILLING_MARKUP_PERCENT = 400 # Singleton cache _billingServices: Dict[str, "BillingService"] = {} def getService(currentUser: User, mandateId: str, featureInstanceId: str = None, featureCode: str = None) -> "BillingService": """ Factory function to get or create a BillingService instance. Args: currentUser: Current user object mandateId: Mandate ID for context featureInstanceId: Optional feature instance ID featureCode: Optional feature code (e.g., 'automation') Returns: BillingService instance """ cacheKey = f"{currentUser.id}_{mandateId}_{featureInstanceId}" if cacheKey not in _billingServices: _billingServices[cacheKey] = BillingService(currentUser, mandateId, featureInstanceId, featureCode) else: _billingServices[cacheKey].setContext(currentUser, mandateId, featureInstanceId, featureCode) return _billingServices[cacheKey] def _get_feature_code_from_context(context) -> Optional[str]: """Extract featureCode from ServiceCenterContext.""" explicit = getattr(context, "feature_code", None) if explicit and str(explicit).strip(): return str(explicit).strip() if context.workflow and hasattr(context.workflow, "feature") and context.workflow.feature: return getattr(context.workflow.feature, "code", None) return getattr(context.workflow, "featureCode", None) if context.workflow else None class BillingService: """ Central billing service for AI operations. Responsibilities: - Check balance before operations - Record usage costs - Apply pricing markup - Check provider permissions via RBAC Supports both service center (context, get_service) and legacy (user, mandateId, ...) initialization. """ def __init__(self, context_or_user, mandateId=None, featureInstanceId=None, featureCode=None, get_service=None): """ Initialize the billing service. Service center: (context, get_service) - resolver passes exactly these two args Legacy: (currentUser, mandateId, featureInstanceId, featureCode) from getService() factory """ # Detect service center: second arg is callable (get_service) if mandateId is not None and callable(mandateId): ctx = context_or_user get_service = mandateId self.currentUser = ctx.user self.mandateId = ctx.mandate_id or "" self.featureInstanceId = ctx.feature_instance_id self.featureCode = _get_feature_code_from_context(ctx) elif get_service is not None and hasattr(context_or_user, "user"): ctx = context_or_user self.currentUser = ctx.user self.mandateId = ctx.mandate_id or "" self.featureInstanceId = ctx.feature_instance_id self.featureCode = _get_feature_code_from_context(ctx) else: self.currentUser = context_or_user self.mandateId = mandateId or "" self.featureInstanceId = featureInstanceId self.featureCode = featureCode self._billingInterface = getBillingInterface(self.currentUser, self.mandateId) self._settingsCache = None def setContext( self, currentUser: User, mandateId: str, featureInstanceId: str = None, featureCode: str = None ): """Update service context.""" self.currentUser = currentUser self.mandateId = mandateId self.featureInstanceId = featureInstanceId self.featureCode = featureCode self._billingInterface = getBillingInterface(currentUser, mandateId) self._settingsCache = None def _getSettings(self) -> Optional[Dict[str, Any]]: """Get billing settings with caching.""" if self._settingsCache is None: self._settingsCache = self._billingInterface.getSettings(self.mandateId) return self._settingsCache # ========================================================================= # Price Calculation # ========================================================================= def calculatePriceWithMarkup(self, basePriceCHF: float) -> float: """ Calculate final price with markup. The AICore plugins return prices in their original currency (USD). This method applies the configured markup percentage. Args: basePriceCHF: Base price from AI model (actually USD from provider) Returns: Final price in CHF with markup applied """ if basePriceCHF <= 0: return 0.0 # Apply markup (50% = multiply by 1.5) markup_multiplier = 1 + (BILLING_MARKUP_PERCENT / 100) return round(basePriceCHF * markup_multiplier, 6) # ========================================================================= # Balance Operations # ========================================================================= def checkBalance(self, estimatedCost: float = 0.0) -> BillingCheckResult: """ Check if the current user/mandate has sufficient balance. Gate order: 1. Subscription active? (fast, cached) — blocks AI if not 2. Budget sufficient? (existing prepaid logic) Args: estimatedCost: Estimated cost of the operation (with markup applied) Returns: BillingCheckResult indicating if operation is allowed """ subResult = self._checkSubscription() if subResult is not None: return subResult return self._billingInterface.checkBalance( self.mandateId, self.currentUser.id, estimatedCost ) def _checkSubscription(self) -> Optional[BillingCheckResult]: """Return a failing BillingCheckResult if subscription is not active, else None.""" try: from modules.datamodels.datamodelSubscription import SubscriptionStatusEnum from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import ( getService as getSubscriptionService, _subscriptionReasonForStatus, _subscriptionUserActionForStatus, ) subService = getSubscriptionService(self.currentUser, self.mandateId) status = subService.assertActive(self.mandateId) if status in (SubscriptionStatusEnum.ACTIVE, SubscriptionStatusEnum.TRIALING, SubscriptionStatusEnum.PAST_DUE): return None return BillingCheckResult( allowed=False, reason=_subscriptionReasonForStatus(status), upgradeRequired=True, subscriptionUiPath="/admin/billing?tab=subscription", userAction=_subscriptionUserActionForStatus(status), ) except Exception as e: logger.warning(f"Subscription check failed (allowing): {e}") return None def hasBalance(self, estimatedCost: float = 0.0) -> bool: """ Quick check if balance is sufficient. Args: estimatedCost: Estimated cost with markup Returns: True if operation is allowed """ result = self.checkBalance(estimatedCost) return result.allowed def getCurrentBalance(self) -> float: """ Get current balance for the user/mandate. Returns: Current balance in CHF """ result = self.checkBalance(0.0) return result.currentBalance or 0.0 # ========================================================================= # Usage Recording # ========================================================================= def recordUsage( self, priceCHF: float, workflowId: str = None, aicoreProvider: str = None, aicoreModel: str = None, description: str = None, processingTime: float = None, bytesSent: int = None, bytesReceived: int = None, errorCount: int = None ) -> Optional[Dict[str, Any]]: """Record AI usage cost as a billing transaction with markup applied.""" if priceCHF <= 0: return None finalPrice = self.calculatePriceWithMarkup(priceCHF) if finalPrice <= 0: return None if not description: description = f"AI Usage: {aicoreModel or aicoreProvider or 'unknown'}" return self._billingInterface.recordUsage( mandateId=self.mandateId, userId=self.currentUser.id, priceCHF=finalPrice, workflowId=workflowId, featureInstanceId=self.featureInstanceId, featureCode=self.featureCode, aicoreProvider=aicoreProvider, aicoreModel=aicoreModel, description=description, processingTime=processingTime, bytesSent=bytesSent, bytesReceived=bytesReceived, errorCount=errorCount ) def getWorkflowCost(self, workflowId: str) -> float: """Get total cost for a workflow from billing transactions.""" return self._billingInterface.getWorkflowCost(workflowId) # ========================================================================= # Provider Permission Check (via RBAC) # ========================================================================= def isProviderAllowed(self, provider: str) -> bool: """ Check if the user has permission to use an AICore provider. Uses RBAC to check for resource permission: resource.aicore.{provider} Args: provider: Provider name (e.g., 'anthropic', 'openai') Returns: True if provider is allowed """ try: from modules.security.rbac import RbacClass from modules.datamodels.datamodelRbac import AccessRuleContext from modules.security.rootAccess import getRootDbAppConnector # Get database connector via established pattern dbApp = getRootDbAppConnector() rbac = RbacClass(dbApp, dbApp) resourceKey = f"resource.aicore.{provider}" # Check if user has view permission for this resource (view = use for RESOURCE context) permissions = rbac.getUserPermissions( self.currentUser, AccessRuleContext.RESOURCE, resourceKey, mandateId=self.mandateId ) return permissions.view except Exception as e: logger.warning(f"Error checking provider permission: {e}") # Default to allowed if RBAC check fails return True def getallowedProviders(self) -> List[str]: """ Get list of AICore providers the user is allowed to use. Returns: List of allowed provider names """ try: from modules.aicore.aicoreModelRegistry import modelRegistry # Get all available providers connectors = modelRegistry.discoverConnectors() allProviders = [c.getConnectorType() for c in connectors] # Filter by RBAC permissions return [p for p in allProviders if self.isProviderAllowed(p)] except Exception as e: logger.warning(f"Error getting allowed providers: {e}") return [] # ========================================================================= # Admin Operations # ========================================================================= def addCredit( self, amount: float, description: str = "Manual credit", referenceType: ReferenceTypeEnum = ReferenceTypeEnum.ADMIN ) -> Optional[Dict[str, Any]]: """ Add credit to the account (admin operation). Args: amount: Amount to credit (positive) description: Transaction description referenceType: Reference type (ADMIN, PAYMENT, SYSTEM) Returns: Created transaction dict or None """ if amount <= 0: return None settings = self._getSettings() if not settings: logger.warning(f"No billing settings for mandate {self.mandateId}") return None account = self._billingInterface.getOrCreateMandateAccount( self.mandateId, initialBalance=0.0 ) # Create credit transaction transaction = BillingTransaction( accountId=account["id"], transactionType=TransactionTypeEnum.CREDIT, amount=amount, description=description, referenceType=referenceType ) return self._billingInterface.createTransaction(transaction) # ========================================================================= # Statistics & Reporting # ========================================================================= def getBalancesForUser(self) -> List[BillingBalanceResponse]: """ Get all billing balances for the current user. Returns: List of balance responses for each mandate """ return self._billingInterface.getBalancesForUser(self.currentUser.id) def getTransactionHistory(self, limit: int = 100) -> List[Dict[str, Any]]: """ Get transaction history for the user across all mandates. Args: limit: Maximum number of transactions Returns: List of transactions """ return self._billingInterface.getTransactionsForUser(self.currentUser.id, limit=limit) # ============================================================================ # Exception Classes # ============================================================================ BILLING_USER_ACTION_TOP_UP_SELF = "TOP_UP_SELF" BILLING_USER_ACTION_CONTACT_MANDATE_ADMIN = "CONTACT_MANDATE_ADMIN" def _defaultInsufficientBalanceUserAction() -> str: return BILLING_USER_ACTION_CONTACT_MANDATE_ADMIN def _buildInsufficientBalanceMessages( currentBalance: float, requiredAmount: float, ) -> tuple: bal_s = f"{currentBalance:.2f}" req_s = f"{requiredAmount:.2f}" msg_de = ( f"Das Mandanten-Budget ist aufgebraucht (aktuell CHF {bal_s}, benötigt mindestens CHF {req_s}). " "Bitte informieren Sie die Administratorin bzw. den Administrator Ihres Mandanten. " "Die in den Billing-Einstellungen hinterlegten Kontakte wurden per E-Mail informiert (falls konfiguriert)." ) msg_en = ( f"The organization budget is exhausted (current CHF {bal_s}, at least CHF {req_s} required). " "Please contact your mandate administrator. Billing notification contacts were emailed if configured." ) return msg_de, msg_en class InsufficientBalanceException(Exception): """Raised when there's insufficient balance for an operation. Carries structured fields for API/SSE clients (userAction, localized hints). """ def __init__( self, currentBalance: float, requiredAmount: float, message: Optional[str] = None, *, mandate_id: str = "", user_action: Optional[str] = None, message_de: Optional[str] = None, message_en: Optional[str] = None, ): self.currentBalance = float(currentBalance) self.requiredAmount = float(requiredAmount) self.mandate_id = mandate_id or "" self.user_action = user_action or _defaultInsufficientBalanceUserAction() if message_de is not None and message_en is not None: self.message_de = message_de self.message_en = message_en self.message = message or message_de elif message: self.message = message self.message_de = message self.message_en = message else: md, me = _buildInsufficientBalanceMessages(self.currentBalance, self.requiredAmount) self.message_de = md self.message_en = me self.message = md super().__init__(self.message) @classmethod def fromBalanceCheck( cls, check: BillingCheckResult, mandate_id: str, required_amount: float, ) -> "InsufficientBalanceException": bal = float(check.currentBalance or 0.0) msg_de, msg_en = _buildInsufficientBalanceMessages(bal, required_amount) return cls( bal, required_amount, message=msg_de, mandate_id=mandate_id or "", message_de=msg_de, message_en=msg_en, ) def toClientDict(self) -> Dict[str, Any]: """Structured payload for HTTP 402, SSE item, or JSON error details.""" out: Dict[str, Any] = { "error": "INSUFFICIENT_BALANCE", "currentBalance": round(self.currentBalance, 4), "requiredAmount": round(self.requiredAmount, 4), "message": self.message, "messageDe": self.message_de, "messageEn": self.message_en, "userAction": self.user_action, } if self.mandate_id: out["mandateId"] = self.mandate_id if self.user_action == BILLING_USER_ACTION_TOP_UP_SELF: out["billingUiPath"] = "/billing" return out class ProviderNotAllowedException(Exception): """Raised when a user doesn't have permission to use an AI provider.""" def __init__(self, provider: str, message: str = None): self.provider = provider self.message = message or f"Provider '{provider}' is not allowed for your role" super().__init__(self.message) class BillingContextError(Exception): """Raised when billing context is incomplete (missing mandateId, user, etc.). This is a FAIL-SAFE error: AI calls MUST NOT proceed without valid billing context. Acts like a 0 CHF credit card pre-authorization check - validates that billing CAN be recorded before any expensive AI operation starts. """ def __init__(self, message: str = None): self.message = message or "Billing context incomplete - AI call blocked" super().__init__(self.message) # Expose exception classes on BillingService so consumers can use service.InsufficientBalanceException # instead of importing from this module BillingService.InsufficientBalanceException = InsufficientBalanceException BillingService.ProviderNotAllowedException = ProviderNotAllowedException BillingService.BillingContextError = BillingContextError