541 lines
19 KiB
Python
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
|