gateway/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py

541 lines
19 KiB
Python

# 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."""
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