billing initial
This commit is contained in:
parent
45eda1e4d4
commit
d118128813
10 changed files with 2182 additions and 1 deletions
3
app.py
3
app.py
|
|
@ -503,6 +503,9 @@ app.include_router(userAccessOverviewRouter)
|
||||||
from modules.routes.routeGdpr import router as gdprRouter
|
from modules.routes.routeGdpr import router as gdprRouter
|
||||||
app.include_router(gdprRouter)
|
app.include_router(gdprRouter)
|
||||||
|
|
||||||
|
from modules.routes.routeBilling import router as billingRouter
|
||||||
|
app.include_router(billingRouter)
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# SYSTEM ROUTES (Navigation, etc.)
|
# SYSTEM ROUTES (Navigation, etc.)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
|
||||||
265
modules/datamodels/datamodelBilling.py
Normal file
265
modules/datamodels/datamodelBilling.py
Normal file
|
|
@ -0,0 +1,265 @@
|
||||||
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
|
"""Billing models: BillingAccount, BillingTransaction, BillingSettings, UsageStatistics."""
|
||||||
|
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
from enum import Enum
|
||||||
|
from datetime import date, datetime
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from modules.shared.attributeUtils import registerModelLabels
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class BillingModelEnum(str, Enum):
|
||||||
|
"""Billing model types."""
|
||||||
|
PREPAY_MANDATE = "PREPAY_MANDATE" # Prepaid budget shared by all users in mandate
|
||||||
|
PREPAY_USER = "PREPAY_USER" # Prepaid budget per user within mandate
|
||||||
|
CREDIT_POSTPAY = "CREDIT_POSTPAY" # Credit with monthly invoice (requires billing address)
|
||||||
|
UNLIMITED = "UNLIMITED" # No cost limitation (internal mandates only)
|
||||||
|
|
||||||
|
|
||||||
|
class AccountTypeEnum(str, Enum):
|
||||||
|
"""Account type for billing accounts."""
|
||||||
|
MANDATE = "MANDATE" # Account for entire mandate
|
||||||
|
USER = "USER" # Account for specific user within mandate
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionTypeEnum(str, Enum):
|
||||||
|
"""Transaction types for billing."""
|
||||||
|
CREDIT = "CREDIT" # Credit/top-up (positive)
|
||||||
|
DEBIT = "DEBIT" # Debit/usage (positive amount, reduces balance)
|
||||||
|
ADJUSTMENT = "ADJUSTMENT" # Manual adjustment by admin
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenceTypeEnum(str, Enum):
|
||||||
|
"""Reference types for transactions."""
|
||||||
|
WORKFLOW = "WORKFLOW" # AI workflow usage
|
||||||
|
PAYMENT = "PAYMENT" # Payment/top-up
|
||||||
|
ADMIN = "ADMIN" # Admin adjustment
|
||||||
|
SYSTEM = "SYSTEM" # System credit (e.g., initial credit)
|
||||||
|
|
||||||
|
|
||||||
|
class PeriodTypeEnum(str, Enum):
|
||||||
|
"""Period types for usage statistics."""
|
||||||
|
DAY = "DAY"
|
||||||
|
MONTH = "MONTH"
|
||||||
|
YEAR = "YEAR"
|
||||||
|
|
||||||
|
|
||||||
|
class BillingAddress(BaseModel):
|
||||||
|
"""Billing address for CREDIT_POSTPAY mandates."""
|
||||||
|
company: str = Field(..., description="Company name")
|
||||||
|
street: str = Field(..., description="Street and number")
|
||||||
|
zip: str = Field(..., description="Postal code")
|
||||||
|
city: str = Field(..., description="City")
|
||||||
|
country: str = Field(default="CH", description="Country code")
|
||||||
|
vatNumber: Optional[str] = Field(None, description="VAT number (optional)")
|
||||||
|
|
||||||
|
|
||||||
|
registerModelLabels(
|
||||||
|
"BillingAddress",
|
||||||
|
{"en": "Billing Address", "de": "Rechnungsadresse"},
|
||||||
|
{
|
||||||
|
"company": {"en": "Company", "de": "Firma"},
|
||||||
|
"street": {"en": "Street", "de": "Strasse"},
|
||||||
|
"zip": {"en": "ZIP", "de": "PLZ"},
|
||||||
|
"city": {"en": "City", "de": "Ort"},
|
||||||
|
"country": {"en": "Country", "de": "Land"},
|
||||||
|
"vatNumber": {"en": "VAT Number", "de": "MwSt-Nummer"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BillingAccount(BaseModel):
|
||||||
|
"""Billing account for mandate or user-mandate combination."""
|
||||||
|
id: str = Field(
|
||||||
|
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
|
||||||
|
)
|
||||||
|
mandateId: str = Field(..., description="Foreign key to Mandate")
|
||||||
|
userId: Optional[str] = Field(None, description="Foreign key to User (only for PREPAY_USER)")
|
||||||
|
accountType: AccountTypeEnum = Field(..., description="Account type: MANDATE or USER")
|
||||||
|
balance: float = Field(default=0.0, description="Current balance in CHF")
|
||||||
|
creditLimit: Optional[float] = Field(None, description="Credit limit in CHF (only for CREDIT_POSTPAY)")
|
||||||
|
warningThreshold: float = Field(default=0.0, description="Warning threshold in CHF")
|
||||||
|
lastWarningAt: Optional[datetime] = Field(None, description="Last warning sent timestamp")
|
||||||
|
enabled: bool = Field(default=True, description="Account is active")
|
||||||
|
|
||||||
|
|
||||||
|
registerModelLabels(
|
||||||
|
"BillingAccount",
|
||||||
|
{"en": "Billing Account", "de": "Abrechnungskonto"},
|
||||||
|
{
|
||||||
|
"id": {"en": "ID", "de": "ID"},
|
||||||
|
"mandateId": {"en": "Mandate ID", "de": "Mandanten-ID"},
|
||||||
|
"userId": {"en": "User ID", "de": "Benutzer-ID"},
|
||||||
|
"accountType": {"en": "Account Type", "de": "Kontotyp"},
|
||||||
|
"balance": {"en": "Balance (CHF)", "de": "Guthaben (CHF)"},
|
||||||
|
"creditLimit": {"en": "Credit Limit (CHF)", "de": "Kreditlimit (CHF)"},
|
||||||
|
"warningThreshold": {"en": "Warning Threshold (CHF)", "de": "Warnschwelle (CHF)"},
|
||||||
|
"lastWarningAt": {"en": "Last Warning", "de": "Letzte Warnung"},
|
||||||
|
"enabled": {"en": "Enabled", "de": "Aktiv"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BillingTransaction(BaseModel):
|
||||||
|
"""Single billing transaction (credit, debit, adjustment)."""
|
||||||
|
id: str = Field(
|
||||||
|
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
|
||||||
|
)
|
||||||
|
accountId: str = Field(..., description="Foreign key to BillingAccount")
|
||||||
|
transactionType: TransactionTypeEnum = Field(..., description="Transaction type")
|
||||||
|
amount: float = Field(..., description="Amount in CHF (always positive)")
|
||||||
|
description: str = Field(..., description="Transaction description")
|
||||||
|
|
||||||
|
# Reference to source
|
||||||
|
referenceType: Optional[ReferenceTypeEnum] = Field(None, description="Reference type")
|
||||||
|
referenceId: Optional[str] = Field(None, description="Reference ID")
|
||||||
|
|
||||||
|
# Context for workflow transactions
|
||||||
|
workflowId: Optional[str] = Field(None, description="Workflow ID (for WORKFLOW transactions)")
|
||||||
|
featureInstanceId: Optional[str] = Field(None, description="Feature instance ID")
|
||||||
|
featureCode: Optional[str] = Field(None, description="Feature code (e.g., chatplayground, automation)")
|
||||||
|
aicoreProvider: Optional[str] = Field(None, description="AICore provider (anthropic, openai, etc.)")
|
||||||
|
|
||||||
|
|
||||||
|
registerModelLabels(
|
||||||
|
"BillingTransaction",
|
||||||
|
{"en": "Billing Transaction", "de": "Transaktion"},
|
||||||
|
{
|
||||||
|
"id": {"en": "ID", "de": "ID"},
|
||||||
|
"accountId": {"en": "Account ID", "de": "Konto-ID"},
|
||||||
|
"transactionType": {"en": "Type", "de": "Typ"},
|
||||||
|
"amount": {"en": "Amount (CHF)", "de": "Betrag (CHF)"},
|
||||||
|
"description": {"en": "Description", "de": "Beschreibung"},
|
||||||
|
"referenceType": {"en": "Reference Type", "de": "Referenztyp"},
|
||||||
|
"referenceId": {"en": "Reference ID", "de": "Referenz-ID"},
|
||||||
|
"workflowId": {"en": "Workflow ID", "de": "Workflow-ID"},
|
||||||
|
"featureInstanceId": {"en": "Feature Instance ID", "de": "Feature-Instanz-ID"},
|
||||||
|
"featureCode": {"en": "Feature Code", "de": "Feature-Code"},
|
||||||
|
"aicoreProvider": {"en": "AI Provider", "de": "AI-Anbieter"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BillingSettings(BaseModel):
|
||||||
|
"""Billing settings per mandate."""
|
||||||
|
id: str = Field(
|
||||||
|
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
|
||||||
|
)
|
||||||
|
mandateId: str = Field(..., description="Foreign key to Mandate (UNIQUE)")
|
||||||
|
billingModel: BillingModelEnum = Field(..., description="Billing model")
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
defaultUserCredit: float = Field(default=10.0, description="Initial credit in CHF for new users (PREPAY_USER)")
|
||||||
|
warningThresholdPercent: float = Field(default=10.0, description="Warning threshold as percentage")
|
||||||
|
blockOnZeroBalance: bool = Field(default=True, description="Block AI features when balance is zero")
|
||||||
|
|
||||||
|
# Billing address (required for CREDIT_POSTPAY)
|
||||||
|
billingAddress: Optional[BillingAddress] = Field(None, description="Billing address")
|
||||||
|
|
||||||
|
# Notifications
|
||||||
|
notifyEmails: List[str] = Field(default_factory=list, description="Email addresses for billing notifications")
|
||||||
|
notifyOnWarning: bool = Field(default=True, description="Send email when warning threshold is reached")
|
||||||
|
|
||||||
|
|
||||||
|
registerModelLabels(
|
||||||
|
"BillingSettings",
|
||||||
|
{"en": "Billing Settings", "de": "Abrechnungseinstellungen"},
|
||||||
|
{
|
||||||
|
"id": {"en": "ID", "de": "ID"},
|
||||||
|
"mandateId": {"en": "Mandate ID", "de": "Mandanten-ID"},
|
||||||
|
"billingModel": {"en": "Billing Model", "de": "Abrechnungsmodell"},
|
||||||
|
"defaultUserCredit": {"en": "Default User Credit (CHF)", "de": "Standard-Startguthaben (CHF)"},
|
||||||
|
"warningThresholdPercent": {"en": "Warning Threshold (%)", "de": "Warnschwelle (%)"},
|
||||||
|
"blockOnZeroBalance": {"en": "Block on Zero Balance", "de": "Bei 0 blockieren"},
|
||||||
|
"billingAddress": {"en": "Billing Address", "de": "Rechnungsadresse"},
|
||||||
|
"notifyEmails": {"en": "Notification Emails", "de": "Benachrichtigungs-Emails"},
|
||||||
|
"notifyOnWarning": {"en": "Notify on Warning", "de": "Bei Warnung benachrichtigen"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UsageStatistics(BaseModel):
|
||||||
|
"""Aggregated usage statistics for quick retrieval."""
|
||||||
|
id: str = Field(
|
||||||
|
default_factory=lambda: str(uuid.uuid4()), description="Primary key"
|
||||||
|
)
|
||||||
|
accountId: str = Field(..., description="Foreign key to BillingAccount")
|
||||||
|
periodType: PeriodTypeEnum = Field(..., description="Period type")
|
||||||
|
periodStart: date = Field(..., description="Period start date")
|
||||||
|
|
||||||
|
# Aggregated values
|
||||||
|
totalCostCHF: float = Field(default=0.0, description="Total cost in CHF")
|
||||||
|
transactionCount: int = Field(default=0, description="Number of transactions")
|
||||||
|
|
||||||
|
# Breakdown by provider
|
||||||
|
costByProvider: Dict[str, float] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description="Cost breakdown by provider (e.g., {'anthropic': 12.50, 'openai': 8.30})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Breakdown by feature
|
||||||
|
costByFeature: Dict[str, float] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description="Cost breakdown by feature (e.g., {'chatplayground': 15.00, 'automation': 5.80})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
registerModelLabels(
|
||||||
|
"UsageStatistics",
|
||||||
|
{"en": "Usage Statistics", "de": "Nutzungsstatistik"},
|
||||||
|
{
|
||||||
|
"id": {"en": "ID", "de": "ID"},
|
||||||
|
"accountId": {"en": "Account ID", "de": "Konto-ID"},
|
||||||
|
"periodType": {"en": "Period Type", "de": "Periodentyp"},
|
||||||
|
"periodStart": {"en": "Period Start", "de": "Periodenbeginn"},
|
||||||
|
"totalCostCHF": {"en": "Total Cost (CHF)", "de": "Gesamtkosten (CHF)"},
|
||||||
|
"transactionCount": {"en": "Transaction Count", "de": "Anzahl Transaktionen"},
|
||||||
|
"costByProvider": {"en": "Cost by Provider", "de": "Kosten nach Anbieter"},
|
||||||
|
"costByFeature": {"en": "Cost by Feature", "de": "Kosten nach Feature"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Response Models for API
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class BillingBalanceResponse(BaseModel):
|
||||||
|
"""Response model for balance endpoint."""
|
||||||
|
mandateId: str
|
||||||
|
mandateName: str
|
||||||
|
billingModel: BillingModelEnum
|
||||||
|
balance: float
|
||||||
|
currency: str = "CHF"
|
||||||
|
warningThreshold: float
|
||||||
|
isWarning: bool
|
||||||
|
creditLimit: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
class BillingStatisticsChartData(BaseModel):
|
||||||
|
"""Chart data point for statistics."""
|
||||||
|
label: str
|
||||||
|
totalCost: float
|
||||||
|
byProvider: Dict[str, float]
|
||||||
|
|
||||||
|
|
||||||
|
class BillingStatisticsResponse(BaseModel):
|
||||||
|
"""Response model for statistics endpoint."""
|
||||||
|
mandateId: str
|
||||||
|
period: PeriodTypeEnum
|
||||||
|
year: int
|
||||||
|
month: Optional[int] = None
|
||||||
|
currency: str = "CHF"
|
||||||
|
data: List[BillingStatisticsChartData]
|
||||||
|
totals: Dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class BillingCheckResult(BaseModel):
|
||||||
|
"""Result of a billing balance check."""
|
||||||
|
allowed: bool
|
||||||
|
reason: Optional[str] = None
|
||||||
|
currentBalance: Optional[float] = None
|
||||||
|
requiredAmount: Optional[float] = None
|
||||||
|
billingModel: Optional[BillingModelEnum] = None
|
||||||
|
|
@ -25,6 +25,7 @@ class AutomationDefinition(BaseModel):
|
||||||
eventId: Optional[str] = Field(None, description="Event ID from event management (None if not registered)", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
eventId: Optional[str] = Field(None, description="Event ID from event management (None if not registered)", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||||
status: Optional[str] = Field(None, description="Status: 'active' if event is registered, 'inactive' if not (computed, readonly)", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
status: Optional[str] = Field(None, description="Status: 'active' if event is registered, 'inactive' if not (computed, readonly)", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||||
executionLogs: List[Dict[str, Any]] = Field(default_factory=list, description="List of execution logs, each containing timestamp, workflowId, status, and messages", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
executionLogs: List[Dict[str, Any]] = Field(default_factory=list, description="List of execution logs, each containing timestamp, workflowId, status, and messages", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||||
|
allowedProviders: List[str] = Field(default_factory=list, description="List of allowed AICore providers (e.g., 'anthropic', 'openai'). Empty means all RBAC-permitted providers are allowed.", json_schema_extra={"frontend_type": "multiselect", "frontend_readonly": False, "frontend_required": False})
|
||||||
|
|
||||||
|
|
||||||
registerModelLabels(
|
registerModelLabels(
|
||||||
|
|
@ -42,6 +43,7 @@ registerModelLabels(
|
||||||
"eventId": {"en": "Event ID", "ge": "Event-ID", "fr": "ID de l'événement"},
|
"eventId": {"en": "Event ID", "ge": "Event-ID", "fr": "ID de l'événement"},
|
||||||
"status": {"en": "Status", "ge": "Status", "fr": "Statut"},
|
"status": {"en": "Status", "ge": "Status", "fr": "Statut"},
|
||||||
"executionLogs": {"en": "Execution Logs", "ge": "Ausführungsprotokolle", "fr": "Journaux d'exécution"},
|
"executionLogs": {"en": "Execution Logs", "ge": "Ausführungsprotokolle", "fr": "Journaux d'exécution"},
|
||||||
|
"allowedProviders": {"en": "Allowed Providers", "ge": "Erlaubte Provider", "fr": "Fournisseurs autorisés"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,10 @@ def initBootstrap(db: DatabaseConnector) -> None:
|
||||||
# Initialize feature instances for root mandate
|
# Initialize feature instances for root mandate
|
||||||
if mandateId:
|
if mandateId:
|
||||||
initRootMandateFeatures(db, mandateId)
|
initRootMandateFeatures(db, mandateId)
|
||||||
|
|
||||||
|
# Initialize billing settings for root mandate
|
||||||
|
if mandateId:
|
||||||
|
initRootMandateBilling(mandateId)
|
||||||
|
|
||||||
|
|
||||||
def initAutomationTemplates(dbApp: DatabaseConnector, adminUserId: Optional[str] = None) -> None:
|
def initAutomationTemplates(dbApp: DatabaseConnector, adminUserId: Optional[str] = None) -> None:
|
||||||
|
|
@ -1192,6 +1196,115 @@ def _createResourceContextRules(db: DatabaseConnector) -> None:
|
||||||
db.recordCreate(AccessRule, rule)
|
db.recordCreate(AccessRule, rule)
|
||||||
|
|
||||||
logger.info(f"Created {len(resourceRules)} RESOURCE context rules")
|
logger.info(f"Created {len(resourceRules)} RESOURCE context rules")
|
||||||
|
|
||||||
|
# Create AICore provider RBAC rules
|
||||||
|
_createAicoreProviderRules(db)
|
||||||
|
|
||||||
|
|
||||||
|
def _createAicoreProviderRules(db: DatabaseConnector) -> None:
|
||||||
|
"""
|
||||||
|
Create RBAC rules for AICore providers (resource.aicore.{provider}).
|
||||||
|
All roles get access to all providers by default.
|
||||||
|
|
||||||
|
NOTE: Provider list is dynamically discovered from AICore model registry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database connector instance
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from modules.aicore.aicoreModelRegistry import modelRegistry
|
||||||
|
|
||||||
|
# Discover available connectors dynamically
|
||||||
|
connectors = modelRegistry.discoverConnectors()
|
||||||
|
providers = [c.getConnectorType() for c in connectors]
|
||||||
|
|
||||||
|
if not providers:
|
||||||
|
logger.warning("No AICore providers discovered, skipping provider RBAC rules")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"Creating RBAC rules for AICore providers: {providers}")
|
||||||
|
|
||||||
|
providerRules = []
|
||||||
|
|
||||||
|
# All roles get access to all providers (as per requirement)
|
||||||
|
for roleLabel in ["admin", "user", "viewer"]:
|
||||||
|
roleId = _getRoleId(db, roleLabel)
|
||||||
|
if not roleId:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for provider in providers:
|
||||||
|
resourceKey = f"resource.aicore.{provider}"
|
||||||
|
|
||||||
|
# Check if rule already exists
|
||||||
|
existingRules = db.getRecordset(
|
||||||
|
AccessRule,
|
||||||
|
recordFilter={
|
||||||
|
"roleId": roleId,
|
||||||
|
"context": AccessRuleContext.RESOURCE.value,
|
||||||
|
"item": resourceKey
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not existingRules:
|
||||||
|
providerRules.append(AccessRule(
|
||||||
|
roleId=roleId,
|
||||||
|
context=AccessRuleContext.RESOURCE,
|
||||||
|
item=resourceKey,
|
||||||
|
view=True, # view=True means "can use" for RESOURCE context
|
||||||
|
read=None,
|
||||||
|
create=None,
|
||||||
|
update=None,
|
||||||
|
delete=None,
|
||||||
|
))
|
||||||
|
|
||||||
|
for rule in providerRules:
|
||||||
|
db.recordCreate(AccessRule, rule)
|
||||||
|
|
||||||
|
if providerRules:
|
||||||
|
logger.info(f"Created {len(providerRules)} AICore provider RBAC rules")
|
||||||
|
else:
|
||||||
|
logger.debug("All AICore provider RBAC rules already exist")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to create AICore provider RBAC rules: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def initRootMandateBilling(mandateId: str) -> None:
|
||||||
|
"""
|
||||||
|
Initialize billing settings for root mandate.
|
||||||
|
Root mandate uses PREPAY_USER model with 10 CHF initial credit per user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mandateId: Root mandate ID
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from modules.interfaces.interfaceDbBilling import _getRootInterface
|
||||||
|
from modules.datamodels.datamodelBilling import BillingSettings, BillingModelEnum
|
||||||
|
|
||||||
|
billingInterface = _getRootInterface()
|
||||||
|
|
||||||
|
# Check if settings already exist
|
||||||
|
existingSettings = billingInterface.getSettings(mandateId)
|
||||||
|
if existingSettings:
|
||||||
|
logger.info("Billing settings for root mandate already exist")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create billing settings for root mandate
|
||||||
|
settings = BillingSettings(
|
||||||
|
mandateId=mandateId,
|
||||||
|
billingModel=BillingModelEnum.PREPAY_USER,
|
||||||
|
defaultUserCredit=10.0, # 10 CHF initial credit per user
|
||||||
|
warningThresholdPercent=10.0,
|
||||||
|
blockOnZeroBalance=True,
|
||||||
|
notifyOnWarning=True
|
||||||
|
)
|
||||||
|
|
||||||
|
billingInterface.createSettings(settings)
|
||||||
|
logger.info(f"Created billing settings for root mandate: PREPAY_USER with 10 CHF default credit")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Don't fail bootstrap if billing init fails
|
||||||
|
logger.warning(f"Failed to initialize root mandate billing (non-critical): {e}")
|
||||||
|
|
||||||
|
|
||||||
def assignInitialUserMemberships(
|
def assignInitialUserMemberships(
|
||||||
|
|
|
||||||
734
modules/interfaces/interfaceDbBilling.py
Normal file
734
modules/interfaces/interfaceDbBilling.py
Normal file
|
|
@ -0,0 +1,734 @@
|
||||||
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
|
"""
|
||||||
|
Interface for Billing operations.
|
||||||
|
Manages billing accounts, transactions, and usage statistics.
|
||||||
|
|
||||||
|
All billing data is stored in the poweron_billing database.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any, List, Optional
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||||
|
from modules.shared.configuration import APP_CONFIG
|
||||||
|
from modules.shared.timeUtils import getUtcTimestamp
|
||||||
|
from modules.datamodels.datamodelUam import User
|
||||||
|
from modules.datamodels.datamodelBilling import (
|
||||||
|
BillingAccount,
|
||||||
|
BillingTransaction,
|
||||||
|
BillingSettings,
|
||||||
|
UsageStatistics,
|
||||||
|
BillingAddress,
|
||||||
|
BillingModelEnum,
|
||||||
|
AccountTypeEnum,
|
||||||
|
TransactionTypeEnum,
|
||||||
|
ReferenceTypeEnum,
|
||||||
|
PeriodTypeEnum,
|
||||||
|
BillingBalanceResponse,
|
||||||
|
BillingCheckResult,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Singleton factory for BillingObjects instances
|
||||||
|
_billingInterfaces: Dict[str, "BillingObjects"] = {}
|
||||||
|
|
||||||
|
# Database name for billing
|
||||||
|
BILLING_DATABASE = "poweron_billing"
|
||||||
|
|
||||||
|
|
||||||
|
def getInterface(currentUser: User, mandateId: str = None) -> "BillingObjects":
|
||||||
|
"""
|
||||||
|
Factory function to get or create a BillingObjects instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
currentUser: Current user object
|
||||||
|
mandateId: Mandate ID for context
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
BillingObjects instance
|
||||||
|
"""
|
||||||
|
cacheKey = f"{currentUser.id}_{mandateId}"
|
||||||
|
|
||||||
|
if cacheKey not in _billingInterfaces:
|
||||||
|
_billingInterfaces[cacheKey] = BillingObjects(currentUser, mandateId)
|
||||||
|
else:
|
||||||
|
_billingInterfaces[cacheKey].setUserContext(currentUser, mandateId)
|
||||||
|
|
||||||
|
return _billingInterfaces[cacheKey]
|
||||||
|
|
||||||
|
|
||||||
|
def _getRootInterface() -> "BillingObjects":
|
||||||
|
"""Get interface with system access for bootstrap operations."""
|
||||||
|
from modules.security.rootAccess import getRootUser
|
||||||
|
rootUser = getRootUser()
|
||||||
|
return BillingObjects(rootUser, mandateId=None)
|
||||||
|
|
||||||
|
|
||||||
|
class BillingObjects:
|
||||||
|
"""
|
||||||
|
Interface for billing operations.
|
||||||
|
Manages accounts, transactions, settings, and statistics.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, currentUser: Optional[User] = None, mandateId: str = None):
|
||||||
|
"""
|
||||||
|
Initialize the billing interface.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
currentUser: Current user object
|
||||||
|
mandateId: Mandate ID for context
|
||||||
|
"""
|
||||||
|
self.currentUser = currentUser
|
||||||
|
self.userId = currentUser.id if currentUser else None
|
||||||
|
self.mandateId = mandateId
|
||||||
|
|
||||||
|
# Initialize database connection
|
||||||
|
self._initializeDatabase()
|
||||||
|
|
||||||
|
def _initializeDatabase(self):
|
||||||
|
"""Initialize database connection."""
|
||||||
|
self.db = DatabaseConnector(
|
||||||
|
databaseName=BILLING_DATABASE,
|
||||||
|
host=APP_CONFIG.get('Database_Host', 'localhost'),
|
||||||
|
port=int(APP_CONFIG.get('Database_Port', '5432')),
|
||||||
|
user=APP_CONFIG.get('Database_User', 'admin'),
|
||||||
|
password=APP_CONFIG.get('Database_Password', 'admin')
|
||||||
|
)
|
||||||
|
|
||||||
|
def setUserContext(self, currentUser: User, mandateId: str = None):
|
||||||
|
"""
|
||||||
|
Update user context.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
currentUser: Current user object
|
||||||
|
mandateId: Mandate ID for context
|
||||||
|
"""
|
||||||
|
self.currentUser = currentUser
|
||||||
|
self.userId = currentUser.id if currentUser else None
|
||||||
|
self.mandateId = mandateId
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# BillingSettings Operations
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def getSettings(self, mandateId: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get billing settings for a mandate.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mandateId: Mandate ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
BillingSettings dict or None if not found
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
results = self.db.getRecordset(
|
||||||
|
BillingSettings,
|
||||||
|
filterDict={"mandateId": mandateId}
|
||||||
|
)
|
||||||
|
return results[0] if results else None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting billing settings: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def createSettings(self, settings: BillingSettings) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Create billing settings for a mandate.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
settings: BillingSettings object
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created settings dict
|
||||||
|
"""
|
||||||
|
settingsDict = settings.model_dump(exclude_none=True)
|
||||||
|
|
||||||
|
# Handle nested BillingAddress
|
||||||
|
if settings.billingAddress:
|
||||||
|
settingsDict["billingAddress"] = settings.billingAddress.model_dump()
|
||||||
|
|
||||||
|
return self.db.recordCreate(BillingSettings, settingsDict)
|
||||||
|
|
||||||
|
def updateSettings(self, settingsId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Update billing settings.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
settingsId: Settings ID
|
||||||
|
updates: Fields to update
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated settings dict or None
|
||||||
|
"""
|
||||||
|
return self.db.recordModify(BillingSettings, settingsId, updates)
|
||||||
|
|
||||||
|
def getOrCreateSettings(self, mandateId: str, defaultModel: BillingModelEnum = BillingModelEnum.UNLIMITED) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get or create billing settings for a mandate.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mandateId: Mandate ID
|
||||||
|
defaultModel: Default billing model if creating
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
BillingSettings dict
|
||||||
|
"""
|
||||||
|
existing = self.getSettings(mandateId)
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
settings = BillingSettings(
|
||||||
|
mandateId=mandateId,
|
||||||
|
billingModel=defaultModel,
|
||||||
|
defaultUserCredit=10.0,
|
||||||
|
warningThresholdPercent=10.0,
|
||||||
|
blockOnZeroBalance=True,
|
||||||
|
notifyOnWarning=True
|
||||||
|
)
|
||||||
|
return self.createSettings(settings)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# BillingAccount Operations
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def getAccount(self, accountId: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get a billing account by ID."""
|
||||||
|
try:
|
||||||
|
results = self.db.getRecordset(
|
||||||
|
BillingAccount,
|
||||||
|
filterDict={"id": accountId}
|
||||||
|
)
|
||||||
|
return results[0] if results else None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting billing account: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def getMandateAccount(self, mandateId: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get the mandate-level billing account.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mandateId: Mandate ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
BillingAccount dict or None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
results = self.db.getRecordset(
|
||||||
|
BillingAccount,
|
||||||
|
filterDict={
|
||||||
|
"mandateId": mandateId,
|
||||||
|
"accountType": AccountTypeEnum.MANDATE.value
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return results[0] if results else None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting mandate account: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def getUserAccount(self, mandateId: str, userId: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get a user-level billing account within a mandate.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mandateId: Mandate ID
|
||||||
|
userId: User ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
BillingAccount dict or None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
results = self.db.getRecordset(
|
||||||
|
BillingAccount,
|
||||||
|
filterDict={
|
||||||
|
"mandateId": mandateId,
|
||||||
|
"userId": userId,
|
||||||
|
"accountType": AccountTypeEnum.USER.value
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return results[0] if results else None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting user account: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def createAccount(self, account: BillingAccount) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Create a new billing account.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
account: BillingAccount object
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created account dict
|
||||||
|
"""
|
||||||
|
accountDict = account.model_dump(exclude_none=True)
|
||||||
|
return self.db.recordCreate(BillingAccount, accountDict)
|
||||||
|
|
||||||
|
def updateAccountBalance(self, accountId: str, newBalance: float) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Update account balance atomically.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
accountId: Account ID
|
||||||
|
newBalance: New balance value
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated account dict or None
|
||||||
|
"""
|
||||||
|
return self.db.recordModify(BillingAccount, accountId, {"balance": newBalance})
|
||||||
|
|
||||||
|
def getOrCreateMandateAccount(self, mandateId: str, initialBalance: float = 0.0) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get or create a mandate-level billing account.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mandateId: Mandate ID
|
||||||
|
initialBalance: Initial balance if creating
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
BillingAccount dict
|
||||||
|
"""
|
||||||
|
existing = self.getMandateAccount(mandateId)
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
account = BillingAccount(
|
||||||
|
mandateId=mandateId,
|
||||||
|
accountType=AccountTypeEnum.MANDATE,
|
||||||
|
balance=initialBalance,
|
||||||
|
enabled=True
|
||||||
|
)
|
||||||
|
return self.createAccount(account)
|
||||||
|
|
||||||
|
def getOrCreateUserAccount(self, mandateId: str, userId: str, initialBalance: float = 0.0) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get or create a user-level billing account.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mandateId: Mandate ID
|
||||||
|
userId: User ID
|
||||||
|
initialBalance: Initial balance if creating
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
BillingAccount dict
|
||||||
|
"""
|
||||||
|
existing = self.getUserAccount(mandateId, userId)
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
account = BillingAccount(
|
||||||
|
mandateId=mandateId,
|
||||||
|
userId=userId,
|
||||||
|
accountType=AccountTypeEnum.USER,
|
||||||
|
balance=initialBalance,
|
||||||
|
enabled=True
|
||||||
|
)
|
||||||
|
created = self.createAccount(account)
|
||||||
|
|
||||||
|
# If initial balance > 0, create a SYSTEM credit transaction
|
||||||
|
if initialBalance > 0:
|
||||||
|
self.createTransaction(BillingTransaction(
|
||||||
|
accountId=created["id"],
|
||||||
|
transactionType=TransactionTypeEnum.CREDIT,
|
||||||
|
amount=initialBalance,
|
||||||
|
description="Initial credit for new user",
|
||||||
|
referenceType=ReferenceTypeEnum.SYSTEM
|
||||||
|
))
|
||||||
|
|
||||||
|
return created
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# BillingTransaction Operations
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def createTransaction(self, transaction: BillingTransaction) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Create a new billing transaction and update account balance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
transaction: BillingTransaction object
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created transaction dict
|
||||||
|
"""
|
||||||
|
# Get current account
|
||||||
|
account = self.getAccount(transaction.accountId)
|
||||||
|
if not account:
|
||||||
|
raise ValueError(f"Account {transaction.accountId} not found")
|
||||||
|
|
||||||
|
currentBalance = account.get("balance", 0.0)
|
||||||
|
|
||||||
|
# Calculate new balance
|
||||||
|
if transaction.transactionType == TransactionTypeEnum.CREDIT:
|
||||||
|
newBalance = currentBalance + transaction.amount
|
||||||
|
elif transaction.transactionType == TransactionTypeEnum.DEBIT:
|
||||||
|
newBalance = currentBalance - transaction.amount
|
||||||
|
else: # ADJUSTMENT
|
||||||
|
newBalance = currentBalance + transaction.amount # Can be positive or negative
|
||||||
|
|
||||||
|
# Create transaction
|
||||||
|
transactionDict = transaction.model_dump(exclude_none=True)
|
||||||
|
created = self.db.recordCreate(BillingTransaction, transactionDict)
|
||||||
|
|
||||||
|
# Update account balance
|
||||||
|
self.updateAccountBalance(transaction.accountId, newBalance)
|
||||||
|
|
||||||
|
logger.info(f"Billing transaction created: {transaction.transactionType.value} {transaction.amount} CHF, "
|
||||||
|
f"balance: {currentBalance} -> {newBalance}")
|
||||||
|
|
||||||
|
return created
|
||||||
|
|
||||||
|
def getTransactions(
|
||||||
|
self,
|
||||||
|
accountId: str,
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
startDate: date = None,
|
||||||
|
endDate: date = None
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get transactions for an account.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
accountId: Account ID
|
||||||
|
limit: Maximum number of results
|
||||||
|
offset: Offset for pagination
|
||||||
|
startDate: Filter by start date
|
||||||
|
endDate: Filter by end date
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of transaction dicts
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
filterDict = {"accountId": accountId}
|
||||||
|
results = self.db.getRecordset(BillingTransaction, filterDict=filterDict)
|
||||||
|
|
||||||
|
# Apply date filters if provided
|
||||||
|
if startDate or endDate:
|
||||||
|
filtered = []
|
||||||
|
for t in results:
|
||||||
|
createdAt = t.get("_createdAt")
|
||||||
|
if createdAt:
|
||||||
|
tDate = createdAt.date() if isinstance(createdAt, datetime) else createdAt
|
||||||
|
if startDate and tDate < startDate:
|
||||||
|
continue
|
||||||
|
if endDate and tDate > endDate:
|
||||||
|
continue
|
||||||
|
filtered.append(t)
|
||||||
|
results = filtered
|
||||||
|
|
||||||
|
# Sort by creation date descending
|
||||||
|
results.sort(key=lambda x: x.get("_createdAt", ""), reverse=True)
|
||||||
|
|
||||||
|
# Apply pagination
|
||||||
|
return results[offset:offset + limit]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting transactions: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def getTransactionsByMandate(self, mandateId: str, limit: int = 100) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get all transactions for a mandate (across all accounts).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mandateId: Mandate ID
|
||||||
|
limit: Maximum number of results
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of transaction dicts
|
||||||
|
"""
|
||||||
|
# Get all accounts for mandate
|
||||||
|
accounts = self.db.getRecordset(BillingAccount, filterDict={"mandateId": mandateId})
|
||||||
|
|
||||||
|
allTransactions = []
|
||||||
|
for account in accounts:
|
||||||
|
transactions = self.getTransactions(account["id"], limit=limit)
|
||||||
|
allTransactions.extend(transactions)
|
||||||
|
|
||||||
|
# Sort by creation date descending and limit
|
||||||
|
allTransactions.sort(key=lambda x: x.get("_createdAt", ""), reverse=True)
|
||||||
|
return allTransactions[:limit]
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Balance Check Operations
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def checkBalance(self, mandateId: str, userId: str, estimatedCost: float) -> BillingCheckResult:
|
||||||
|
"""
|
||||||
|
Check if there's sufficient balance for an operation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mandateId: Mandate ID
|
||||||
|
userId: User ID
|
||||||
|
estimatedCost: Estimated cost of the operation
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
BillingCheckResult
|
||||||
|
"""
|
||||||
|
settings = self.getSettings(mandateId)
|
||||||
|
if not settings:
|
||||||
|
# No settings = no billing = allowed
|
||||||
|
return BillingCheckResult(allowed=True, billingModel=BillingModelEnum.UNLIMITED)
|
||||||
|
|
||||||
|
billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value))
|
||||||
|
|
||||||
|
# UNLIMITED = always allowed
|
||||||
|
if billingModel == BillingModelEnum.UNLIMITED:
|
||||||
|
return BillingCheckResult(allowed=True, billingModel=billingModel)
|
||||||
|
|
||||||
|
# Get the relevant account
|
||||||
|
if billingModel == BillingModelEnum.PREPAY_USER:
|
||||||
|
account = self.getUserAccount(mandateId, userId)
|
||||||
|
else:
|
||||||
|
account = self.getMandateAccount(mandateId)
|
||||||
|
|
||||||
|
if not account:
|
||||||
|
# No account = no balance = potentially blocked
|
||||||
|
if settings.get("blockOnZeroBalance", True):
|
||||||
|
return BillingCheckResult(
|
||||||
|
allowed=False,
|
||||||
|
reason="NO_ACCOUNT",
|
||||||
|
currentBalance=0.0,
|
||||||
|
requiredAmount=estimatedCost,
|
||||||
|
billingModel=billingModel
|
||||||
|
)
|
||||||
|
return BillingCheckResult(allowed=True, currentBalance=0.0, billingModel=billingModel)
|
||||||
|
|
||||||
|
currentBalance = account.get("balance", 0.0)
|
||||||
|
|
||||||
|
# CREDIT_POSTPAY with credit limit check
|
||||||
|
if billingModel == BillingModelEnum.CREDIT_POSTPAY:
|
||||||
|
creditLimit = account.get("creditLimit")
|
||||||
|
if creditLimit and abs(currentBalance) + estimatedCost > creditLimit:
|
||||||
|
return BillingCheckResult(
|
||||||
|
allowed=False,
|
||||||
|
reason="CREDIT_LIMIT_EXCEEDED",
|
||||||
|
currentBalance=currentBalance,
|
||||||
|
requiredAmount=estimatedCost,
|
||||||
|
billingModel=billingModel
|
||||||
|
)
|
||||||
|
return BillingCheckResult(allowed=True, currentBalance=currentBalance, billingModel=billingModel)
|
||||||
|
|
||||||
|
# PREPAY models - check balance
|
||||||
|
if currentBalance < estimatedCost:
|
||||||
|
if settings.get("blockOnZeroBalance", True):
|
||||||
|
return BillingCheckResult(
|
||||||
|
allowed=False,
|
||||||
|
reason="INSUFFICIENT_BALANCE",
|
||||||
|
currentBalance=currentBalance,
|
||||||
|
requiredAmount=estimatedCost,
|
||||||
|
billingModel=billingModel
|
||||||
|
)
|
||||||
|
|
||||||
|
return BillingCheckResult(allowed=True, currentBalance=currentBalance, billingModel=billingModel)
|
||||||
|
|
||||||
|
def recordUsage(
|
||||||
|
self,
|
||||||
|
mandateId: str,
|
||||||
|
userId: str,
|
||||||
|
priceCHF: float,
|
||||||
|
workflowId: str = None,
|
||||||
|
featureInstanceId: str = None,
|
||||||
|
featureCode: str = None,
|
||||||
|
aicoreProvider: str = None,
|
||||||
|
description: str = "AI Usage"
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Record usage cost as a billing transaction.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mandateId: Mandate ID
|
||||||
|
userId: User ID
|
||||||
|
priceCHF: Cost in CHF
|
||||||
|
workflowId: Optional workflow ID
|
||||||
|
featureInstanceId: Optional feature instance ID
|
||||||
|
featureCode: Optional feature code
|
||||||
|
aicoreProvider: Optional AICore provider name
|
||||||
|
description: Transaction description
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created transaction dict or None
|
||||||
|
"""
|
||||||
|
if priceCHF <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
settings = self.getSettings(mandateId)
|
||||||
|
if not settings:
|
||||||
|
logger.debug(f"No billing settings for mandate {mandateId}, skipping usage recording")
|
||||||
|
return None
|
||||||
|
|
||||||
|
billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value))
|
||||||
|
|
||||||
|
# UNLIMITED = no transaction recording
|
||||||
|
if billingModel == BillingModelEnum.UNLIMITED:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get or create the relevant account
|
||||||
|
if billingModel == BillingModelEnum.PREPAY_USER:
|
||||||
|
account = self.getOrCreateUserAccount(mandateId, userId)
|
||||||
|
else:
|
||||||
|
account = self.getOrCreateMandateAccount(mandateId)
|
||||||
|
|
||||||
|
# Create debit transaction
|
||||||
|
transaction = BillingTransaction(
|
||||||
|
accountId=account["id"],
|
||||||
|
transactionType=TransactionTypeEnum.DEBIT,
|
||||||
|
amount=priceCHF,
|
||||||
|
description=description,
|
||||||
|
referenceType=ReferenceTypeEnum.WORKFLOW,
|
||||||
|
workflowId=workflowId,
|
||||||
|
featureInstanceId=featureInstanceId,
|
||||||
|
featureCode=featureCode,
|
||||||
|
aicoreProvider=aicoreProvider
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.createTransaction(transaction)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Statistics Operations
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def getUsageStatistics(
|
||||||
|
self,
|
||||||
|
accountId: str,
|
||||||
|
periodType: PeriodTypeEnum,
|
||||||
|
year: int,
|
||||||
|
month: int = None
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get usage statistics for an account.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
accountId: Account ID
|
||||||
|
periodType: Period type (DAY, MONTH, YEAR)
|
||||||
|
year: Year
|
||||||
|
month: Month (for DAY period type)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of statistics dicts
|
||||||
|
"""
|
||||||
|
filterDict = {
|
||||||
|
"accountId": accountId,
|
||||||
|
"periodType": periodType.value
|
||||||
|
}
|
||||||
|
|
||||||
|
results = self.db.getRecordset(UsageStatistics, filterDict=filterDict)
|
||||||
|
|
||||||
|
# Filter by year
|
||||||
|
filtered = [s for s in results if s.get("periodStart") and s["periodStart"].year == year]
|
||||||
|
|
||||||
|
# Filter by month if specified
|
||||||
|
if month and periodType == PeriodTypeEnum.DAY:
|
||||||
|
filtered = [s for s in filtered if s["periodStart"].month == month]
|
||||||
|
|
||||||
|
return sorted(filtered, key=lambda x: x.get("periodStart", date.min))
|
||||||
|
|
||||||
|
def calculateStatisticsFromTransactions(
|
||||||
|
self,
|
||||||
|
accountId: str,
|
||||||
|
startDate: date,
|
||||||
|
endDate: date
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Calculate statistics from transactions for a period.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
accountId: Account ID
|
||||||
|
startDate: Start date
|
||||||
|
endDate: End date
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Statistics dict
|
||||||
|
"""
|
||||||
|
transactions = self.getTransactions(accountId, limit=10000, startDate=startDate, endDate=endDate)
|
||||||
|
|
||||||
|
# Filter only DEBIT transactions (usage)
|
||||||
|
debits = [t for t in transactions if t.get("transactionType") == TransactionTypeEnum.DEBIT.value]
|
||||||
|
|
||||||
|
totalCost = sum(t.get("amount", 0) for t in debits)
|
||||||
|
|
||||||
|
# Calculate by provider
|
||||||
|
costByProvider = {}
|
||||||
|
for t in debits:
|
||||||
|
provider = t.get("aicoreProvider", "unknown")
|
||||||
|
costByProvider[provider] = costByProvider.get(provider, 0) + t.get("amount", 0)
|
||||||
|
|
||||||
|
# Calculate by feature
|
||||||
|
costByFeature = {}
|
||||||
|
for t in debits:
|
||||||
|
feature = t.get("featureCode", "unknown")
|
||||||
|
costByFeature[feature] = costByFeature.get(feature, 0) + t.get("amount", 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"totalCostCHF": totalCost,
|
||||||
|
"transactionCount": len(debits),
|
||||||
|
"costByProvider": costByProvider,
|
||||||
|
"costByFeature": costByFeature
|
||||||
|
}
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Utility Methods
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def getBalancesForUser(self, userId: str) -> List[BillingBalanceResponse]:
|
||||||
|
"""
|
||||||
|
Get all billing balances for a user across mandates.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
userId: User ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of BillingBalanceResponse
|
||||||
|
"""
|
||||||
|
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
|
||||||
|
|
||||||
|
balances = []
|
||||||
|
|
||||||
|
# Get all mandates the user belongs to
|
||||||
|
try:
|
||||||
|
appInterface = getAppInterface(self.currentUser)
|
||||||
|
userMandates = appInterface.getUserMandates(userId)
|
||||||
|
|
||||||
|
for um in userMandates:
|
||||||
|
mandateId = um.get("mandateId")
|
||||||
|
mandate = appInterface.getMandate(mandateId)
|
||||||
|
if not mandate:
|
||||||
|
continue
|
||||||
|
|
||||||
|
settings = self.getSettings(mandateId)
|
||||||
|
if not settings:
|
||||||
|
continue
|
||||||
|
|
||||||
|
billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value))
|
||||||
|
|
||||||
|
# Get the relevant account
|
||||||
|
if billingModel == BillingModelEnum.PREPAY_USER:
|
||||||
|
account = self.getUserAccount(mandateId, userId)
|
||||||
|
elif billingModel in [BillingModelEnum.PREPAY_MANDATE, BillingModelEnum.CREDIT_POSTPAY]:
|
||||||
|
account = self.getMandateAccount(mandateId)
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not account:
|
||||||
|
continue
|
||||||
|
|
||||||
|
balance = account.get("balance", 0.0)
|
||||||
|
warningThreshold = account.get("warningThreshold", 0.0)
|
||||||
|
|
||||||
|
balances.append(BillingBalanceResponse(
|
||||||
|
mandateId=mandateId,
|
||||||
|
mandateName=mandate.get("name", ""),
|
||||||
|
billingModel=billingModel,
|
||||||
|
balance=balance,
|
||||||
|
warningThreshold=warningThreshold,
|
||||||
|
isWarning=balance <= warningThreshold,
|
||||||
|
creditLimit=account.get("creditLimit")
|
||||||
|
))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting balances for user: {e}")
|
||||||
|
|
||||||
|
return balances
|
||||||
557
modules/routes/routeBilling.py
Normal file
557
modules/routes/routeBilling.py
Normal file
|
|
@ -0,0 +1,557 @@
|
||||||
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
|
"""
|
||||||
|
Billing routes for the backend API.
|
||||||
|
Implements the endpoints for billing management and usage tracking.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- User endpoints: View balance, transactions, statistics
|
||||||
|
- Admin endpoints: Manage settings, add credits, view all accounts
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Response, Query
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
from fastapi import status
|
||||||
|
import logging
|
||||||
|
from datetime import date, datetime
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
# Import auth module
|
||||||
|
from modules.auth import limiter, requireSysAdmin, getRequestContext, RequestContext
|
||||||
|
|
||||||
|
# Import billing components
|
||||||
|
from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface
|
||||||
|
from modules.services.serviceBilling.mainServiceBilling import getService as getBillingService
|
||||||
|
from modules.datamodels.datamodelBilling import (
|
||||||
|
BillingAccount,
|
||||||
|
BillingTransaction,
|
||||||
|
BillingSettings,
|
||||||
|
BillingAddress,
|
||||||
|
BillingModelEnum,
|
||||||
|
TransactionTypeEnum,
|
||||||
|
ReferenceTypeEnum,
|
||||||
|
PeriodTypeEnum,
|
||||||
|
BillingBalanceResponse,
|
||||||
|
BillingStatisticsResponse,
|
||||||
|
BillingStatisticsChartData,
|
||||||
|
BillingCheckResult,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Configure logger
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Request/Response Models
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class CreditAddRequest(BaseModel):
|
||||||
|
"""Request model for adding credit to an account."""
|
||||||
|
userId: Optional[str] = Field(None, description="Target user ID (for PREPAY_USER model)")
|
||||||
|
amount: float = Field(..., gt=0, description="Amount to credit in CHF")
|
||||||
|
description: str = Field(default="Manual credit", description="Transaction description")
|
||||||
|
|
||||||
|
|
||||||
|
class BillingSettingsUpdate(BaseModel):
|
||||||
|
"""Request model for updating billing settings."""
|
||||||
|
billingModel: Optional[BillingModelEnum] = None
|
||||||
|
defaultUserCredit: Optional[float] = Field(None, ge=0)
|
||||||
|
warningThresholdPercent: Optional[float] = Field(None, ge=0, le=100)
|
||||||
|
blockOnZeroBalance: Optional[bool] = None
|
||||||
|
notifyOnWarning: Optional[bool] = None
|
||||||
|
notifyEmails: Optional[List[str]] = None
|
||||||
|
billingAddress: Optional[BillingAddress] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionResponse(BaseModel):
|
||||||
|
"""Response model for a billing transaction."""
|
||||||
|
id: str
|
||||||
|
accountId: str
|
||||||
|
transactionType: TransactionTypeEnum
|
||||||
|
amount: float
|
||||||
|
description: str
|
||||||
|
referenceType: Optional[ReferenceTypeEnum]
|
||||||
|
workflowId: Optional[str]
|
||||||
|
featureCode: Optional[str]
|
||||||
|
aicoreProvider: Optional[str]
|
||||||
|
createdAt: Optional[datetime]
|
||||||
|
|
||||||
|
|
||||||
|
class AccountSummary(BaseModel):
|
||||||
|
"""Summary of a billing account."""
|
||||||
|
id: str
|
||||||
|
mandateId: str
|
||||||
|
userId: Optional[str]
|
||||||
|
accountType: str
|
||||||
|
balance: float
|
||||||
|
creditLimit: Optional[float]
|
||||||
|
warningThreshold: float
|
||||||
|
enabled: bool
|
||||||
|
|
||||||
|
|
||||||
|
class UsageReportResponse(BaseModel):
|
||||||
|
"""Usage report for a period."""
|
||||||
|
period: str
|
||||||
|
totalCost: float
|
||||||
|
transactionCount: int
|
||||||
|
costByProvider: Dict[str, float]
|
||||||
|
costByFeature: Dict[str, float]
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Router Setup
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/api/billing",
|
||||||
|
tags=["Billing"],
|
||||||
|
responses={404: {"description": "Not found"}}
|
||||||
|
)
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# User Endpoints
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@router.get("/balance", response_model=List[BillingBalanceResponse])
|
||||||
|
@limiter.limit("60/minute")
|
||||||
|
async def getBalance(
|
||||||
|
request: Request,
|
||||||
|
ctx: RequestContext = Depends(getRequestContext)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get billing balances for all mandates the current user belongs to.
|
||||||
|
Returns balance information for each mandate.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
billingService = getBillingService(
|
||||||
|
ctx.currentUser,
|
||||||
|
ctx.mandateId,
|
||||||
|
featureCode="billing"
|
||||||
|
)
|
||||||
|
|
||||||
|
balances = billingService.getBalancesForUser()
|
||||||
|
return balances
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting billing balance: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/balance/{mandateId}", response_model=BillingBalanceResponse)
|
||||||
|
@limiter.limit("60/minute")
|
||||||
|
async def getBalanceForMandate(
|
||||||
|
request: Request,
|
||||||
|
mandateId: str = Path(..., description="Mandate ID"),
|
||||||
|
ctx: RequestContext = Depends(getRequestContext)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get billing balance for a specific mandate.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
billingService = getBillingService(
|
||||||
|
ctx.currentUser,
|
||||||
|
mandateId,
|
||||||
|
featureCode="billing"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check balance
|
||||||
|
checkResult = billingService.checkBalance(0.0)
|
||||||
|
|
||||||
|
# Get mandate name from app interface
|
||||||
|
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
|
||||||
|
appInterface = getAppInterface(ctx.currentUser, mandateId=mandateId)
|
||||||
|
mandate = appInterface.getMandate(mandateId)
|
||||||
|
mandateName = mandate.get("name", "") if mandate else ""
|
||||||
|
|
||||||
|
return BillingBalanceResponse(
|
||||||
|
mandateId=mandateId,
|
||||||
|
mandateName=mandateName,
|
||||||
|
billingModel=checkResult.billingModel or BillingModelEnum.UNLIMITED,
|
||||||
|
balance=checkResult.currentBalance or 0.0,
|
||||||
|
warningThreshold=0.0, # TODO: Get from account
|
||||||
|
isWarning=False,
|
||||||
|
creditLimit=None
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting billing balance for mandate {mandateId}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/transactions", response_model=List[TransactionResponse])
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
async def getTransactions(
|
||||||
|
request: Request,
|
||||||
|
limit: int = Query(default=50, ge=1, le=500),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
ctx: RequestContext = Depends(getRequestContext)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get transaction history for the current mandate.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
billingService = getBillingService(
|
||||||
|
ctx.currentUser,
|
||||||
|
ctx.mandateId,
|
||||||
|
featureCode="billing"
|
||||||
|
)
|
||||||
|
|
||||||
|
transactions = billingService.getTransactionHistory(limit=limit)
|
||||||
|
|
||||||
|
# Convert to response model
|
||||||
|
result = []
|
||||||
|
for t in transactions[offset:offset + limit]:
|
||||||
|
result.append(TransactionResponse(
|
||||||
|
id=t.get("id"),
|
||||||
|
accountId=t.get("accountId"),
|
||||||
|
transactionType=TransactionTypeEnum(t.get("transactionType", "DEBIT")),
|
||||||
|
amount=t.get("amount", 0.0),
|
||||||
|
description=t.get("description", ""),
|
||||||
|
referenceType=ReferenceTypeEnum(t["referenceType"]) if t.get("referenceType") else None,
|
||||||
|
workflowId=t.get("workflowId"),
|
||||||
|
featureCode=t.get("featureCode"),
|
||||||
|
aicoreProvider=t.get("aicoreProvider"),
|
||||||
|
createdAt=t.get("_createdAt")
|
||||||
|
))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting billing transactions: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/statistics/{period}", response_model=UsageReportResponse)
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
async def getStatistics(
|
||||||
|
request: Request,
|
||||||
|
period: str = Path(..., description="Period: 'day', 'month', or 'year'"),
|
||||||
|
year: int = Query(..., description="Year"),
|
||||||
|
month: Optional[int] = Query(None, description="Month (1-12, required for 'day' period)"),
|
||||||
|
ctx: RequestContext = Depends(getRequestContext)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get usage statistics for a period.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Validate period
|
||||||
|
if period not in ["day", "month", "year"]:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid period. Use 'day', 'month', or 'year'")
|
||||||
|
|
||||||
|
if period == "day" and not month:
|
||||||
|
raise HTTPException(status_code=400, detail="Month is required for 'day' period")
|
||||||
|
|
||||||
|
billingInterface = getBillingInterface(ctx.currentUser, ctx.mandateId)
|
||||||
|
settings = billingInterface.getSettings(ctx.mandateId)
|
||||||
|
|
||||||
|
if not settings:
|
||||||
|
return UsageReportResponse(
|
||||||
|
period=period,
|
||||||
|
totalCost=0.0,
|
||||||
|
transactionCount=0,
|
||||||
|
costByProvider={},
|
||||||
|
costByFeature={}
|
||||||
|
)
|
||||||
|
|
||||||
|
billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value))
|
||||||
|
|
||||||
|
# Get the relevant account
|
||||||
|
if billingModel == BillingModelEnum.PREPAY_USER:
|
||||||
|
account = billingInterface.getUserAccount(ctx.mandateId, ctx.currentUser.id)
|
||||||
|
else:
|
||||||
|
account = billingInterface.getMandateAccount(ctx.mandateId)
|
||||||
|
|
||||||
|
if not account:
|
||||||
|
return UsageReportResponse(
|
||||||
|
period=period,
|
||||||
|
totalCost=0.0,
|
||||||
|
transactionCount=0,
|
||||||
|
costByProvider={},
|
||||||
|
costByFeature={}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate date range
|
||||||
|
if period == "day":
|
||||||
|
startDate = date(year, month, 1)
|
||||||
|
if month == 12:
|
||||||
|
endDate = date(year + 1, 1, 1)
|
||||||
|
else:
|
||||||
|
endDate = date(year, month + 1, 1)
|
||||||
|
elif period == "month":
|
||||||
|
startDate = date(year, 1, 1)
|
||||||
|
endDate = date(year + 1, 1, 1)
|
||||||
|
else: # year
|
||||||
|
startDate = date(year, 1, 1)
|
||||||
|
endDate = date(year + 1, 1, 1)
|
||||||
|
|
||||||
|
# Get statistics from transactions
|
||||||
|
stats = billingInterface.calculateStatisticsFromTransactions(
|
||||||
|
account["id"],
|
||||||
|
startDate,
|
||||||
|
endDate
|
||||||
|
)
|
||||||
|
|
||||||
|
return UsageReportResponse(
|
||||||
|
period=period,
|
||||||
|
totalCost=stats.get("totalCostCHF", 0.0),
|
||||||
|
transactionCount=stats.get("transactionCount", 0),
|
||||||
|
costByProvider=stats.get("costByProvider", {}),
|
||||||
|
costByFeature=stats.get("costByFeature", {})
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting billing statistics: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/providers", response_model=List[str])
|
||||||
|
@limiter.limit("60/minute")
|
||||||
|
async def getAllowedProviders(
|
||||||
|
request: Request,
|
||||||
|
ctx: RequestContext = Depends(getRequestContext)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get list of AICore providers the current user is allowed to use.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
billingService = getBillingService(
|
||||||
|
ctx.currentUser,
|
||||||
|
ctx.mandateId,
|
||||||
|
featureCode="billing"
|
||||||
|
)
|
||||||
|
|
||||||
|
return billingService.getallowedProviders()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting allowed providers: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Admin Endpoints
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@router.get("/admin/settings/{mandateId}", response_model=Dict[str, Any])
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
@requireSysAdmin
|
||||||
|
async def getSettingsAdmin(
|
||||||
|
request: Request,
|
||||||
|
mandateId: str = Path(..., description="Mandate ID"),
|
||||||
|
ctx: RequestContext = Depends(getRequestContext)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get billing settings for a mandate (SysAdmin only).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
billingInterface = getBillingInterface(ctx.currentUser, mandateId)
|
||||||
|
settings = billingInterface.getSettings(mandateId)
|
||||||
|
|
||||||
|
if not settings:
|
||||||
|
raise HTTPException(status_code=404, detail="Billing settings not found")
|
||||||
|
|
||||||
|
return settings
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting billing settings: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/settings/{mandateId}", response_model=Dict[str, Any])
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
@requireSysAdmin
|
||||||
|
async def createOrUpdateSettings(
|
||||||
|
request: Request,
|
||||||
|
mandateId: str = Path(..., description="Mandate ID"),
|
||||||
|
settingsUpdate: BillingSettingsUpdate = Body(...),
|
||||||
|
ctx: RequestContext = Depends(getRequestContext)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Create or update billing settings for a mandate (SysAdmin only).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
billingInterface = getBillingInterface(ctx.currentUser, mandateId)
|
||||||
|
existingSettings = billingInterface.getSettings(mandateId)
|
||||||
|
|
||||||
|
if existingSettings:
|
||||||
|
# Update existing settings
|
||||||
|
updates = settingsUpdate.model_dump(exclude_none=True)
|
||||||
|
if updates:
|
||||||
|
result = billingInterface.updateSettings(existingSettings["id"], updates)
|
||||||
|
return result or existingSettings
|
||||||
|
return existingSettings
|
||||||
|
else:
|
||||||
|
# Create new settings
|
||||||
|
from modules.datamodels.datamodelBilling import BillingSettings
|
||||||
|
|
||||||
|
newSettings = BillingSettings(
|
||||||
|
mandateId=mandateId,
|
||||||
|
billingModel=settingsUpdate.billingModel or BillingModelEnum.UNLIMITED,
|
||||||
|
defaultUserCredit=settingsUpdate.defaultUserCredit or 10.0,
|
||||||
|
warningThresholdPercent=settingsUpdate.warningThresholdPercent or 10.0,
|
||||||
|
blockOnZeroBalance=settingsUpdate.blockOnZeroBalance if settingsUpdate.blockOnZeroBalance is not None else True,
|
||||||
|
notifyOnWarning=settingsUpdate.notifyOnWarning if settingsUpdate.notifyOnWarning is not None else True,
|
||||||
|
notifyEmails=settingsUpdate.notifyEmails or [],
|
||||||
|
billingAddress=settingsUpdate.billingAddress
|
||||||
|
)
|
||||||
|
|
||||||
|
return billingInterface.createSettings(newSettings)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating billing settings: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/credit/{mandateId}", response_model=Dict[str, Any])
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
@requireSysAdmin
|
||||||
|
async def addCredit(
|
||||||
|
request: Request,
|
||||||
|
mandateId: str = Path(..., description="Mandate ID"),
|
||||||
|
creditRequest: CreditAddRequest = Body(...),
|
||||||
|
ctx: RequestContext = Depends(getRequestContext)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Add credit to a billing account (SysAdmin only).
|
||||||
|
For PREPAY_USER model, specify userId. For PREPAY_MANDATE, leave userId empty.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get settings to determine billing model
|
||||||
|
billingInterface = getBillingInterface(ctx.currentUser, mandateId)
|
||||||
|
settings = billingInterface.getSettings(mandateId)
|
||||||
|
|
||||||
|
if not settings:
|
||||||
|
raise HTTPException(status_code=404, detail="Billing settings not found for this mandate")
|
||||||
|
|
||||||
|
billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value))
|
||||||
|
|
||||||
|
# Validate request based on billing model
|
||||||
|
if billingModel == BillingModelEnum.PREPAY_USER:
|
||||||
|
if not creditRequest.userId:
|
||||||
|
raise HTTPException(status_code=400, detail="userId is required for PREPAY_USER model")
|
||||||
|
|
||||||
|
# Create user-level account if needed and add credit
|
||||||
|
account = billingInterface.getOrCreateUserAccount(
|
||||||
|
mandateId,
|
||||||
|
creditRequest.userId,
|
||||||
|
initialBalance=0.0
|
||||||
|
)
|
||||||
|
elif billingModel in [BillingModelEnum.PREPAY_MANDATE, BillingModelEnum.CREDIT_POSTPAY]:
|
||||||
|
# Create mandate-level account if needed and add credit
|
||||||
|
account = billingInterface.getOrCreateMandateAccount(mandateId, initialBalance=0.0)
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Cannot add credit to {billingModel.value} billing model")
|
||||||
|
|
||||||
|
# Create credit transaction
|
||||||
|
from modules.datamodels.datamodelBilling import BillingTransaction
|
||||||
|
|
||||||
|
transaction = BillingTransaction(
|
||||||
|
accountId=account["id"],
|
||||||
|
transactionType=TransactionTypeEnum.CREDIT,
|
||||||
|
amount=creditRequest.amount,
|
||||||
|
description=creditRequest.description,
|
||||||
|
referenceType=ReferenceTypeEnum.ADMIN
|
||||||
|
)
|
||||||
|
|
||||||
|
result = billingInterface.createTransaction(transaction)
|
||||||
|
|
||||||
|
logger.info(f"Added {creditRequest.amount} CHF credit to account {account['id']} in mandate {mandateId}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error adding credit: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/accounts/{mandateId}", response_model=List[AccountSummary])
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
@requireSysAdmin
|
||||||
|
async def getAccounts(
|
||||||
|
request: Request,
|
||||||
|
mandateId: str = Path(..., description="Mandate ID"),
|
||||||
|
ctx: RequestContext = Depends(getRequestContext)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get all billing accounts for a mandate (SysAdmin only).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
billingInterface = getBillingInterface(ctx.currentUser, mandateId)
|
||||||
|
|
||||||
|
# Get all accounts for this mandate
|
||||||
|
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||||
|
from modules.shared.configuration import APP_CONFIG
|
||||||
|
from modules.datamodels.datamodelBilling import BillingAccount
|
||||||
|
|
||||||
|
db = DatabaseConnector(
|
||||||
|
databaseName="poweron_billing",
|
||||||
|
host=APP_CONFIG.get('Database_Host', 'localhost'),
|
||||||
|
port=int(APP_CONFIG.get('Database_Port', '5432')),
|
||||||
|
user=APP_CONFIG.get('Database_User', 'admin'),
|
||||||
|
password=APP_CONFIG.get('Database_Password', 'admin')
|
||||||
|
)
|
||||||
|
|
||||||
|
accounts = db.getRecordset(BillingAccount, filterDict={"mandateId": mandateId})
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for acc in accounts:
|
||||||
|
result.append(AccountSummary(
|
||||||
|
id=acc.get("id"),
|
||||||
|
mandateId=acc.get("mandateId"),
|
||||||
|
userId=acc.get("userId"),
|
||||||
|
accountType=acc.get("accountType"),
|
||||||
|
balance=acc.get("balance", 0.0),
|
||||||
|
creditLimit=acc.get("creditLimit"),
|
||||||
|
warningThreshold=acc.get("warningThreshold", 0.0),
|
||||||
|
enabled=acc.get("enabled", True)
|
||||||
|
))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting billing accounts: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/transactions/{mandateId}", response_model=List[TransactionResponse])
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
@requireSysAdmin
|
||||||
|
async def getTransactionsAdmin(
|
||||||
|
request: Request,
|
||||||
|
mandateId: str = Path(..., description="Mandate ID"),
|
||||||
|
limit: int = Query(default=100, ge=1, le=1000),
|
||||||
|
ctx: RequestContext = Depends(getRequestContext)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get all transactions for a mandate (SysAdmin only).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
billingInterface = getBillingInterface(ctx.currentUser, mandateId)
|
||||||
|
transactions = billingInterface.getTransactionsByMandate(mandateId, limit=limit)
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for t in transactions:
|
||||||
|
result.append(TransactionResponse(
|
||||||
|
id=t.get("id"),
|
||||||
|
accountId=t.get("accountId"),
|
||||||
|
transactionType=TransactionTypeEnum(t.get("transactionType", "DEBIT")),
|
||||||
|
amount=t.get("amount", 0.0),
|
||||||
|
description=t.get("description", ""),
|
||||||
|
referenceType=ReferenceTypeEnum(t["referenceType"]) if t.get("referenceType") else None,
|
||||||
|
workflowId=t.get("workflowId"),
|
||||||
|
featureCode=t.get("featureCode"),
|
||||||
|
aicoreProvider=t.get("aicoreProvider"),
|
||||||
|
createdAt=t.get("_createdAt")
|
||||||
|
))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting billing transactions for mandate {mandateId}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
7
modules/services/serviceBilling/__init__.py
Normal file
7
modules/services/serviceBilling/__init__.py
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
|
"""Billing service module."""
|
||||||
|
|
||||||
|
from .mainServiceBilling import BillingService, getService
|
||||||
|
|
||||||
|
__all__ = ["BillingService", "getService"]
|
||||||
408
modules/services/serviceBilling/mainServiceBilling.py
Normal file
408
modules/services/serviceBilling/mainServiceBilling.py
Normal file
|
|
@ -0,0 +1,408 @@
|
||||||
|
# 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 (
|
||||||
|
BillingModelEnum,
|
||||||
|
BillingCheckResult,
|
||||||
|
TransactionTypeEnum,
|
||||||
|
ReferenceTypeEnum,
|
||||||
|
BillingTransaction,
|
||||||
|
BillingBalanceResponse,
|
||||||
|
)
|
||||||
|
from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Markup percentage for internal pricing (50% = 1.5x)
|
||||||
|
BILLING_MARKUP_PERCENT = 50
|
||||||
|
|
||||||
|
# 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., 'chatplayground', '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]
|
||||||
|
|
||||||
|
|
||||||
|
class BillingService:
|
||||||
|
"""
|
||||||
|
Central billing service for AI operations.
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
- Check balance before operations
|
||||||
|
- Record usage costs
|
||||||
|
- Apply pricing markup
|
||||||
|
- Check provider permissions via RBAC
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
currentUser: User,
|
||||||
|
mandateId: str,
|
||||||
|
featureInstanceId: str = None,
|
||||||
|
featureCode: str = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the billing service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
currentUser: Current user object
|
||||||
|
mandateId: Mandate ID
|
||||||
|
featureInstanceId: Optional feature instance ID
|
||||||
|
featureCode: Optional feature code
|
||||||
|
"""
|
||||||
|
self.currentUser = currentUser
|
||||||
|
self.mandateId = mandateId
|
||||||
|
self.featureInstanceId = featureInstanceId
|
||||||
|
self.featureCode = featureCode
|
||||||
|
|
||||||
|
# Get billing interface
|
||||||
|
self._billingInterface = getBillingInterface(currentUser, mandateId)
|
||||||
|
|
||||||
|
# Cache settings
|
||||||
|
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.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
estimatedCost: Estimated cost of the operation (with markup applied)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
BillingCheckResult indicating if operation is allowed
|
||||||
|
"""
|
||||||
|
return self._billingInterface.checkBalance(
|
||||||
|
self.mandateId,
|
||||||
|
self.currentUser.id,
|
||||||
|
estimatedCost
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
description: str = None
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Record AI usage cost as a billing transaction.
|
||||||
|
|
||||||
|
This method:
|
||||||
|
1. Applies the pricing markup
|
||||||
|
2. Creates a DEBIT transaction
|
||||||
|
3. Updates the account balance
|
||||||
|
|
||||||
|
Args:
|
||||||
|
priceCHF: Base price from AI model (before markup)
|
||||||
|
workflowId: Optional workflow ID
|
||||||
|
aicoreProvider: AICore provider name (e.g., 'anthropic', 'openai')
|
||||||
|
description: Optional description
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created transaction dict or None if not recorded
|
||||||
|
"""
|
||||||
|
if priceCHF <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Apply markup
|
||||||
|
finalPrice = self.calculatePriceWithMarkup(priceCHF)
|
||||||
|
|
||||||
|
if finalPrice <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Build description
|
||||||
|
if not description:
|
||||||
|
description = f"AI Usage: {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,
|
||||||
|
description=description
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 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.connectors.connectorDbPostgre import DatabaseConnector
|
||||||
|
from modules.shared.configuration import APP_CONFIG
|
||||||
|
|
||||||
|
# Get database connectors
|
||||||
|
dbApp = DatabaseConnector(
|
||||||
|
databaseName="poweron_app",
|
||||||
|
host=APP_CONFIG.get('Database_Host', 'localhost'),
|
||||||
|
port=int(APP_CONFIG.get('Database_Port', '5432')),
|
||||||
|
user=APP_CONFIG.get('Database_User', 'admin'),
|
||||||
|
password=APP_CONFIG.get('Database_Password', 'admin')
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value))
|
||||||
|
|
||||||
|
# Get or create account
|
||||||
|
if billingModel == BillingModelEnum.PREPAY_USER:
|
||||||
|
account = self._billingInterface.getOrCreateUserAccount(
|
||||||
|
self.mandateId,
|
||||||
|
self.currentUser.id,
|
||||||
|
initialBalance=0.0
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
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 current mandate.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: Maximum number of transactions
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of transactions
|
||||||
|
"""
|
||||||
|
return self._billingInterface.getTransactionsByMandate(self.mandateId, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Exception Classes
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class InsufficientBalanceException(Exception):
|
||||||
|
"""Raised when there's insufficient balance for an operation."""
|
||||||
|
|
||||||
|
def __init__(self, currentBalance: float, requiredAmount: float, message: str = None):
|
||||||
|
self.currentBalance = currentBalance
|
||||||
|
self.requiredAmount = requiredAmount
|
||||||
|
self.message = message or f"Insufficient balance. Current: {currentBalance:.2f} CHF, Required: {requiredAmount:.2f} CHF"
|
||||||
|
super().__init__(self.message)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
@ -674,7 +674,8 @@ class ChatService:
|
||||||
return chatLog
|
return chatLog
|
||||||
|
|
||||||
def storeWorkflowStat(self, workflow: Any, aiResponse: Any, process: str) -> ChatStat:
|
def storeWorkflowStat(self, workflow: Any, aiResponse: Any, process: str) -> ChatStat:
|
||||||
"""Persist workflow-level ChatStat from AiCallResponse and append to workflow stats list."""
|
"""Persist workflow-level ChatStat from AiCallResponse and append to workflow stats list.
|
||||||
|
Also records the usage cost to the billing system if configured."""
|
||||||
try:
|
try:
|
||||||
# Create ChatStat from AiCallResponse data
|
# Create ChatStat from AiCallResponse data
|
||||||
statData = {
|
statData = {
|
||||||
|
|
@ -696,10 +697,69 @@ class ChatService:
|
||||||
workflow.stats = []
|
workflow.stats = []
|
||||||
workflow.stats.append(stat)
|
workflow.stats.append(stat)
|
||||||
|
|
||||||
|
# Record billing transaction if mandateId is available
|
||||||
|
self._recordBillingUsage(workflow, aiResponse, process)
|
||||||
|
|
||||||
return stat
|
return stat
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to store workflow stat: {e}")
|
logger.error(f"Failed to store workflow stat: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
def _recordBillingUsage(self, workflow: Any, aiResponse: Any, process: str) -> None:
|
||||||
|
"""Record AI usage to the billing system.
|
||||||
|
|
||||||
|
This method:
|
||||||
|
1. Gets the mandate context from services
|
||||||
|
2. Records the usage cost with 50% markup via BillingService
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workflow: ChatWorkflow object
|
||||||
|
aiResponse: AI call response with cost information
|
||||||
|
process: Process identifier for the AI call
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Check if we have mandate context
|
||||||
|
mandateId = getattr(self.services, 'mandateId', None)
|
||||||
|
if not mandateId:
|
||||||
|
logger.debug("No mandate context, skipping billing recording")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if there's a cost to record
|
||||||
|
priceCHF = getattr(aiResponse, 'priceCHF', 0.0)
|
||||||
|
if not priceCHF or priceCHF <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Extract provider from model name (e.g., "anthropic.claude-3-sonnet" -> "anthropic")
|
||||||
|
modelName = getattr(aiResponse, 'modelName', '') or ''
|
||||||
|
aicoreProvider = modelName.split('.')[0] if '.' in modelName else 'unknown'
|
||||||
|
|
||||||
|
# Get feature context if available
|
||||||
|
featureInstanceId = getattr(self.services, 'featureInstanceId', None)
|
||||||
|
featureCode = getattr(self.services, 'featureCode', None)
|
||||||
|
|
||||||
|
# Import and use BillingService
|
||||||
|
from modules.services.serviceBilling.mainServiceBilling import getService as getBillingService
|
||||||
|
|
||||||
|
billingService = getBillingService(
|
||||||
|
self.user,
|
||||||
|
mandateId,
|
||||||
|
featureInstanceId=featureInstanceId,
|
||||||
|
featureCode=featureCode
|
||||||
|
)
|
||||||
|
|
||||||
|
# Record the usage (includes 50% markup)
|
||||||
|
billingService.recordUsage(
|
||||||
|
priceCHF=priceCHF,
|
||||||
|
workflowId=workflow.id,
|
||||||
|
aicoreProvider=aicoreProvider,
|
||||||
|
description=f"AI Usage: {process}"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(f"Recorded billing usage: {priceCHF} CHF for {process} (provider: {aicoreProvider})")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Don't fail the main operation if billing recording fails
|
||||||
|
logger.warning(f"Failed to record billing usage (non-critical): {e}")
|
||||||
|
|
||||||
def updateMessage(self, messageId: str, messageData: Dict[str, Any]):
|
def updateMessage(self, messageId: str, messageData: Dict[str, Any]):
|
||||||
"""Update message by delegating to the chat interface"""
|
"""Update message by delegating to the chat interface"""
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,29 @@ NAVIGATION_SECTIONS = [
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "billing",
|
||||||
|
"title": {"en": "BILLING", "de": "BILLING", "fr": "FACTURATION"},
|
||||||
|
"order": 35,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "billing-dashboard",
|
||||||
|
"objectKey": "ui.billing.dashboard",
|
||||||
|
"label": {"en": "Balance", "de": "Guthaben", "fr": "Solde"},
|
||||||
|
"icon": "FaWallet",
|
||||||
|
"path": "/billing",
|
||||||
|
"order": 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "billing-transactions",
|
||||||
|
"objectKey": "ui.billing.transactions",
|
||||||
|
"label": {"en": "Transactions", "de": "Transaktionen", "fr": "Transactions"},
|
||||||
|
"icon": "FaListAlt",
|
||||||
|
"path": "/billing/transactions",
|
||||||
|
"order": 20,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "admin",
|
"id": "admin",
|
||||||
"title": {"en": "ADMINISTRATION", "de": "ADMINISTRATION", "fr": "ADMINISTRATION"},
|
"title": {"en": "ADMINISTRATION", "de": "ADMINISTRATION", "fr": "ADMINISTRATION"},
|
||||||
|
|
@ -178,6 +201,15 @@ NAVIGATION_SECTIONS = [
|
||||||
"order": 50,
|
"order": 50,
|
||||||
"adminOnly": True,
|
"adminOnly": True,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "admin-billing",
|
||||||
|
"objectKey": "ui.admin.billing",
|
||||||
|
"label": {"en": "Billing Administration", "de": "Billing-Verwaltung", "fr": "Administration de facturation"},
|
||||||
|
"icon": "FaMoneyBillAlt",
|
||||||
|
"path": "/admin/billing",
|
||||||
|
"order": 60,
|
||||||
|
"adminOnly": True,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue