billing fixes
This commit is contained in:
parent
8dfb7caf92
commit
a054d12d54
11 changed files with 332 additions and 58 deletions
33
app.py
33
app.py
|
|
@ -312,6 +312,39 @@ async def lifespan(app: FastAPI):
|
||||||
# Register audit log cleanup scheduler
|
# Register audit log cleanup scheduler
|
||||||
from modules.shared.auditLogger import registerAuditLogCleanupScheduler
|
from modules.shared.auditLogger import registerAuditLogCleanupScheduler
|
||||||
registerAuditLogCleanupScheduler()
|
registerAuditLogCleanupScheduler()
|
||||||
|
|
||||||
|
# Ensure billing settings and accounts exist
|
||||||
|
try:
|
||||||
|
from modules.interfaces.interfaceDbBilling import _getRootInterface as getBillingRootInterface
|
||||||
|
from modules.datamodels.datamodelBilling import BillingSettings, BillingModelEnum
|
||||||
|
|
||||||
|
billingInterface = getBillingRootInterface()
|
||||||
|
|
||||||
|
# Ensure root mandate has billing settings
|
||||||
|
rootMandate = rootInterface.getRootMandate()
|
||||||
|
if rootMandate:
|
||||||
|
rootMandateId = rootMandate.get("id") if isinstance(rootMandate, dict) else getattr(rootMandate, "id", None)
|
||||||
|
if rootMandateId:
|
||||||
|
existingSettings = billingInterface.getSettings(rootMandateId)
|
||||||
|
if not existingSettings:
|
||||||
|
settings = BillingSettings(
|
||||||
|
mandateId=rootMandateId,
|
||||||
|
billingModel=BillingModelEnum.PREPAY_USER,
|
||||||
|
defaultUserCredit=10.0,
|
||||||
|
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")
|
||||||
|
|
||||||
|
# Efficient bulk check: Ensure all users have billing accounts (3 queries total)
|
||||||
|
accountsCreated = billingInterface.ensureAllUserAccountsExist()
|
||||||
|
if accountsCreated > 0:
|
||||||
|
logger.info(f"Billing startup: Created {accountsCreated} missing user accounts")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to ensure billing settings/accounts (non-critical): {e}")
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -216,12 +216,14 @@ class AiPrivateLlm(BaseConnectorAi):
|
||||||
availableOllamaModels = availability.get("availableModels", [])
|
availableOllamaModels = availability.get("availableModels", [])
|
||||||
|
|
||||||
# Define all models with their Ollama backend names
|
# Define all models with their Ollama backend names
|
||||||
# Actual model specs (for 32GB RAM server):
|
# Actual model specs (for 31GB RAM + 22GB GPU server):
|
||||||
# - qwen2.5:7b: 7.6B params, 128K context, ~4.7GB RAM (Text)
|
# Context sizes reduced to fit in available RAM
|
||||||
# - qwen2.5vl:7b: 8.29B params, 125K context, ~6GB RAM (Vision)
|
# - qwen2.5:7b: 7.6B params, ~4.7GB RAM (Text) - 8K context
|
||||||
# - granite3.2-vision: 2B params, 16K context, ~2.4GB RAM (Vision)
|
# - qwen2.5vl:7b: 8.29B params, ~6GB RAM (Vision) - 4K context
|
||||||
|
# - granite3.2-vision: 2B params, ~2.4GB RAM (Vision) - 4K context
|
||||||
|
# - deepseek-ocr: ~6.7GB RAM (OCR) - 4K context
|
||||||
modelDefinitions = [
|
modelDefinitions = [
|
||||||
# Text Model (qwen2.5:7b: 7.6B, 128K context)
|
# Text Model (qwen2.5:7b: 7.6B)
|
||||||
{
|
{
|
||||||
"model": AiModel(
|
"model": AiModel(
|
||||||
name="poweron-text-general",
|
name="poweron-text-general",
|
||||||
|
|
@ -229,8 +231,8 @@ class AiPrivateLlm(BaseConnectorAi):
|
||||||
connectorType="privatellm",
|
connectorType="privatellm",
|
||||||
apiUrl=f"{self.baseUrl}/api/analyze",
|
apiUrl=f"{self.baseUrl}/api/analyze",
|
||||||
temperature=0.1,
|
temperature=0.1,
|
||||||
maxTokens=8192,
|
maxTokens=4096,
|
||||||
contextLength=128000, # qwen2.5:7b actual context: 128K
|
contextLength=8192, # Reduced for RAM constraints
|
||||||
costPer1kTokensInput=0.0, # Flat rate pricing
|
costPer1kTokensInput=0.0, # Flat rate pricing
|
||||||
costPer1kTokensOutput=0.0, # Flat rate pricing
|
costPer1kTokensOutput=0.0, # Flat rate pricing
|
||||||
speedRating=8, # Fast and efficient
|
speedRating=8, # Fast and efficient
|
||||||
|
|
@ -247,7 +249,7 @@ class AiPrivateLlm(BaseConnectorAi):
|
||||||
),
|
),
|
||||||
"ollamaModel": "qwen2.5:7b"
|
"ollamaModel": "qwen2.5:7b"
|
||||||
},
|
},
|
||||||
# Vision General Model (qwen2.5vl:7b: 8.29B, 125K context)
|
# Vision General Model (qwen2.5vl:7b: 8.29B)
|
||||||
{
|
{
|
||||||
"model": AiModel(
|
"model": AiModel(
|
||||||
name="poweron-vision-general",
|
name="poweron-vision-general",
|
||||||
|
|
@ -255,8 +257,8 @@ class AiPrivateLlm(BaseConnectorAi):
|
||||||
connectorType="privatellm",
|
connectorType="privatellm",
|
||||||
apiUrl=f"{self.baseUrl}/api/analyze",
|
apiUrl=f"{self.baseUrl}/api/analyze",
|
||||||
temperature=0.2,
|
temperature=0.2,
|
||||||
maxTokens=8192,
|
maxTokens=2048,
|
||||||
contextLength=125000, # qwen2.5vl:7b actual context: 125K
|
contextLength=4096, # Reduced for RAM constraints (vision needs more)
|
||||||
costPer1kTokensInput=0.0, # Flat rate pricing
|
costPer1kTokensInput=0.0, # Flat rate pricing
|
||||||
costPer1kTokensOutput=0.0, # Flat rate pricing
|
costPer1kTokensOutput=0.0, # Flat rate pricing
|
||||||
speedRating=7,
|
speedRating=7,
|
||||||
|
|
@ -273,7 +275,7 @@ class AiPrivateLlm(BaseConnectorAi):
|
||||||
),
|
),
|
||||||
"ollamaModel": "qwen2.5vl:7b"
|
"ollamaModel": "qwen2.5vl:7b"
|
||||||
},
|
},
|
||||||
# Vision Deep Model (granite3.2-vision: 2B, 16K context)
|
# Vision Deep Model (granite3.2-vision: 2B)
|
||||||
{
|
{
|
||||||
"model": AiModel(
|
"model": AiModel(
|
||||||
name="poweron-vision-deep",
|
name="poweron-vision-deep",
|
||||||
|
|
@ -281,8 +283,8 @@ class AiPrivateLlm(BaseConnectorAi):
|
||||||
connectorType="privatellm",
|
connectorType="privatellm",
|
||||||
apiUrl=f"{self.baseUrl}/api/analyze",
|
apiUrl=f"{self.baseUrl}/api/analyze",
|
||||||
temperature=0.1,
|
temperature=0.1,
|
||||||
maxTokens=4096,
|
maxTokens=2048,
|
||||||
contextLength=16000, # granite3.2-vision actual context: 16K
|
contextLength=4096, # Reduced for RAM constraints
|
||||||
costPer1kTokensInput=0.0, # Flat rate pricing
|
costPer1kTokensInput=0.0, # Flat rate pricing
|
||||||
costPer1kTokensOutput=0.0, # Flat rate pricing
|
costPer1kTokensOutput=0.0, # Flat rate pricing
|
||||||
speedRating=9, # Fast due to small 2B model
|
speedRating=9, # Fast due to small 2B model
|
||||||
|
|
|
||||||
|
|
@ -301,6 +301,7 @@ registerModelLabels(
|
||||||
class ChatWorkflow(BaseModel):
|
class ChatWorkflow(BaseModel):
|
||||||
"""Chat workflow container. User-owned, no mandate context."""
|
"""Chat workflow container. User-owned, no mandate context."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||||
|
featureInstanceId: Optional[str] = Field(None, description="Feature instance ID for multi-tenancy isolation", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||||
status: str = Field(default="running", description="Current status of the workflow", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
|
status: str = Field(default="running", description="Current status of the workflow", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
|
||||||
{"value": "running", "label": {"en": "Running", "fr": "En cours"}},
|
{"value": "running", "label": {"en": "Running", "fr": "En cours"}},
|
||||||
{"value": "completed", "label": {"en": "Completed", "fr": "Terminé"}},
|
{"value": "completed", "label": {"en": "Completed", "fr": "Terminé"}},
|
||||||
|
|
@ -374,6 +375,7 @@ registerModelLabels(
|
||||||
{"en": "Chat Workflow", "fr": "Flux de travail de chat"},
|
{"en": "Chat Workflow", "fr": "Flux de travail de chat"},
|
||||||
{
|
{
|
||||||
"id": {"en": "ID", "fr": "ID"},
|
"id": {"en": "ID", "fr": "ID"},
|
||||||
|
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
|
||||||
"status": {"en": "Status", "fr": "Statut"},
|
"status": {"en": "Status", "fr": "Statut"},
|
||||||
"name": {"en": "Name", "fr": "Nom"},
|
"name": {"en": "Name", "fr": "Nom"},
|
||||||
"currentRound": {"en": "Current Round", "fr": "Tour actuel"},
|
"currentRound": {"en": "Current Round", "fr": "Tour actuel"},
|
||||||
|
|
@ -399,7 +401,8 @@ class UserInputRequest(BaseModel):
|
||||||
listFileId: List[str] = Field(default_factory=list, description="List of file IDs")
|
listFileId: List[str] = Field(default_factory=list, description="List of file IDs")
|
||||||
userLanguage: str = Field(default="en", description="User's preferred language")
|
userLanguage: str = Field(default="en", description="User's preferred language")
|
||||||
workflowId: Optional[str] = Field(None, description="Optional ID of the workflow to continue")
|
workflowId: Optional[str] = Field(None, description="Optional ID of the workflow to continue")
|
||||||
preferredProvider: Optional[str] = Field(None, description="Preferred AI provider (e.g., 'anthropic', 'openai')")
|
preferredProvider: Optional[str] = Field(None, description="Preferred AI provider (e.g., 'anthropic', 'openai') - deprecated, use preferredProviders")
|
||||||
|
preferredProviders: Optional[List[str]] = Field(None, description="List of preferred AI providers (multiselect)")
|
||||||
|
|
||||||
|
|
||||||
registerModelLabels(
|
registerModelLabels(
|
||||||
|
|
|
||||||
|
|
@ -131,7 +131,7 @@ async def stop_workflow(
|
||||||
# Validate access and get mandate ID
|
# Validate access and get mandate ID
|
||||||
mandateId = await _validateInstanceAccess(instanceId, context)
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
||||||
|
|
||||||
# Stop workflow
|
# Stop workflow (pass featureInstanceId for proper RBAC filtering)
|
||||||
workflow = await chatStop(
|
workflow = await chatStop(
|
||||||
context.user,
|
context.user,
|
||||||
workflowId,
|
workflowId,
|
||||||
|
|
|
||||||
|
|
@ -1273,34 +1273,57 @@ def initRootMandateBilling(mandateId: str) -> None:
|
||||||
"""
|
"""
|
||||||
Initialize billing settings for root mandate.
|
Initialize billing settings for root mandate.
|
||||||
Root mandate uses PREPAY_USER model with 10 CHF initial credit per user.
|
Root mandate uses PREPAY_USER model with 10 CHF initial credit per user.
|
||||||
|
Also creates billing accounts for all users of the mandate.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
mandateId: Root mandate ID
|
mandateId: Root mandate ID
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from modules.interfaces.interfaceDbBilling import _getRootInterface
|
from modules.interfaces.interfaceDbBilling import _getRootInterface
|
||||||
|
from modules.interfaces.interfaceDbApp import getRootInterface as getAppRootInterface
|
||||||
from modules.datamodels.datamodelBilling import BillingSettings, BillingModelEnum
|
from modules.datamodels.datamodelBilling import BillingSettings, BillingModelEnum
|
||||||
|
|
||||||
billingInterface = _getRootInterface()
|
billingInterface = _getRootInterface()
|
||||||
|
appInterface = getAppRootInterface()
|
||||||
|
|
||||||
# Check if settings already exist
|
# Check if settings already exist
|
||||||
existingSettings = billingInterface.getSettings(mandateId)
|
existingSettings = billingInterface.getSettings(mandateId)
|
||||||
if existingSettings:
|
if existingSettings:
|
||||||
logger.info("Billing settings for root mandate already exist")
|
logger.info("Billing settings for root mandate already exist")
|
||||||
return
|
else:
|
||||||
|
# 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")
|
||||||
|
existingSettings = billingInterface.getSettings(mandateId)
|
||||||
|
|
||||||
# Create billing settings for root mandate
|
# Create billing accounts for all users of the mandate
|
||||||
settings = BillingSettings(
|
if existingSettings:
|
||||||
mandateId=mandateId,
|
billingModel = existingSettings.get("billingModel", "UNLIMITED")
|
||||||
billingModel=BillingModelEnum.PREPAY_USER,
|
if billingModel == BillingModelEnum.PREPAY_USER.value:
|
||||||
defaultUserCredit=10.0, # 10 CHF initial credit per user
|
defaultCredit = existingSettings.get("defaultUserCredit", 10.0)
|
||||||
warningThresholdPercent=10.0,
|
userMandates = appInterface.getUserMandatesByMandate(mandateId)
|
||||||
blockOnZeroBalance=True,
|
accountsCreated = 0
|
||||||
notifyOnWarning=True
|
|
||||||
)
|
for um in userMandates:
|
||||||
|
userId = um.get("userId") if isinstance(um, dict) else getattr(um, "userId", None)
|
||||||
billingInterface.createSettings(settings)
|
if userId:
|
||||||
logger.info(f"Created billing settings for root mandate: PREPAY_USER with 10 CHF default credit")
|
existingAccount = billingInterface.getUserAccount(mandateId, userId)
|
||||||
|
if not existingAccount:
|
||||||
|
billingInterface.getOrCreateUserAccount(mandateId, userId, initialBalance=defaultCredit)
|
||||||
|
accountsCreated += 1
|
||||||
|
logger.debug(f"Created billing account for user {userId}")
|
||||||
|
|
||||||
|
if accountsCreated > 0:
|
||||||
|
logger.info(f"Created {accountsCreated} billing accounts for root mandate users with {defaultCredit} CHF each")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Don't fail bootstrap if billing init fails
|
# Don't fail bootstrap if billing init fails
|
||||||
|
|
|
||||||
|
|
@ -1608,6 +1608,7 @@ class AppObjects:
|
||||||
def createUserMandate(self, userId: str, mandateId: str, roleIds: List[str] = None) -> UserMandate:
|
def createUserMandate(self, userId: str, mandateId: str, roleIds: List[str] = None) -> UserMandate:
|
||||||
"""
|
"""
|
||||||
Create a UserMandate record (add user to mandate).
|
Create a UserMandate record (add user to mandate).
|
||||||
|
Also creates a billing account for the user if billing is configured for PREPAY_USER.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
userId: User ID
|
userId: User ID
|
||||||
|
|
@ -1641,11 +1642,45 @@ class AppObjects:
|
||||||
)
|
)
|
||||||
self.db.recordCreate(UserMandateRole, userMandateRole.model_dump())
|
self.db.recordCreate(UserMandateRole, userMandateRole.model_dump())
|
||||||
|
|
||||||
|
# Create billing account for user if billing is configured
|
||||||
|
self._ensureUserBillingAccount(userId, mandateId)
|
||||||
|
|
||||||
cleanedRecord = {k: v for k, v in createdRecord.items() if not k.startswith("_")}
|
cleanedRecord = {k: v for k, v in createdRecord.items() if not k.startswith("_")}
|
||||||
return UserMandate(**cleanedRecord)
|
return UserMandate(**cleanedRecord)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error creating UserMandate: {e}")
|
logger.error(f"Error creating UserMandate: {e}")
|
||||||
raise ValueError(f"Failed to create UserMandate: {e}")
|
raise ValueError(f"Failed to create UserMandate: {e}")
|
||||||
|
|
||||||
|
def _ensureUserBillingAccount(self, userId: str, mandateId: str) -> None:
|
||||||
|
"""
|
||||||
|
Ensure a user has a billing account for the mandate if billing is configured.
|
||||||
|
Creates account with default credit from settings if billingModel is PREPAY_USER.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
userId: User ID
|
||||||
|
mandateId: Mandate ID
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from modules.interfaces.interfaceDbBilling import _getRootInterface as getBillingRootInterface
|
||||||
|
from modules.datamodels.datamodelBilling import BillingModelEnum
|
||||||
|
|
||||||
|
billingInterface = getBillingRootInterface()
|
||||||
|
settings = billingInterface.getSettings(mandateId)
|
||||||
|
|
||||||
|
if not settings:
|
||||||
|
return # No billing configured for this mandate
|
||||||
|
|
||||||
|
billingModel = settings.get("billingModel", "UNLIMITED")
|
||||||
|
if billingModel != BillingModelEnum.PREPAY_USER.value:
|
||||||
|
return # Only create user accounts for PREPAY_USER model
|
||||||
|
|
||||||
|
defaultCredit = settings.get("defaultUserCredit", 10.0)
|
||||||
|
billingInterface.getOrCreateUserAccount(mandateId, userId, initialBalance=defaultCredit)
|
||||||
|
logger.info(f"Created billing account for user {userId} in mandate {mandateId} with {defaultCredit} CHF")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Don't fail user mandate creation if billing account creation fails
|
||||||
|
logger.warning(f"Failed to create billing account for user {userId} (non-critical): {e}")
|
||||||
|
|
||||||
def deleteUserMandate(self, userId: str, mandateId: str) -> bool:
|
def deleteUserMandate(self, userId: str, mandateId: str) -> bool:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -360,6 +360,98 @@ class BillingObjects:
|
||||||
|
|
||||||
return created
|
return created
|
||||||
|
|
||||||
|
def ensureAllUserAccountsExist(self) -> int:
|
||||||
|
"""
|
||||||
|
Efficiently ensure all users across all mandates have billing accounts.
|
||||||
|
Uses bulk queries to minimize database connections.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of accounts created
|
||||||
|
"""
|
||||||
|
from modules.interfaces.interfaceDbApp import getRootInterface as getAppRootInterface
|
||||||
|
|
||||||
|
try:
|
||||||
|
appInterface = getAppRootInterface()
|
||||||
|
accountsCreated = 0
|
||||||
|
|
||||||
|
# Step 1: Get all billing settings in one query (only PREPAY_USER mandates need user accounts)
|
||||||
|
allSettings = self.db.getRecordset(BillingSettings)
|
||||||
|
prepayUserMandates = {}
|
||||||
|
for s in allSettings:
|
||||||
|
if s.get("billingModel") == BillingModelEnum.PREPAY_USER.value:
|
||||||
|
prepayUserMandates[s.get("mandateId")] = s.get("defaultUserCredit", 10.0)
|
||||||
|
|
||||||
|
if not prepayUserMandates:
|
||||||
|
logger.debug("No PREPAY_USER mandates found, skipping account check")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Step 2: Get all existing USER accounts in one query
|
||||||
|
allAccounts = self.db.getRecordset(
|
||||||
|
BillingAccount,
|
||||||
|
recordFilter={"accountType": AccountTypeEnum.USER.value}
|
||||||
|
)
|
||||||
|
# Build set of existing (mandateId, userId) pairs
|
||||||
|
existingAccountKeys = set()
|
||||||
|
for acc in allAccounts:
|
||||||
|
key = (acc.get("mandateId"), acc.get("userId"))
|
||||||
|
existingAccountKeys.add(key)
|
||||||
|
|
||||||
|
# Step 3: Get all user-mandate combinations in one query
|
||||||
|
allUserMandates = appInterface.db.getRecordset(
|
||||||
|
appInterface.db.getModel("UserMandate"),
|
||||||
|
recordFilter={"enabled": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 4: Find missing accounts and create them
|
||||||
|
for um in allUserMandates:
|
||||||
|
mandateId = um.get("mandateId")
|
||||||
|
userId = um.get("userId")
|
||||||
|
|
||||||
|
if not mandateId or not userId:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Only process mandates with PREPAY_USER billing
|
||||||
|
if mandateId not in prepayUserMandates:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if account already exists (in memory, no DB call)
|
||||||
|
key = (mandateId, userId)
|
||||||
|
if key in existingAccountKeys:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Create missing account
|
||||||
|
defaultCredit = prepayUserMandates[mandateId]
|
||||||
|
account = BillingAccount(
|
||||||
|
mandateId=mandateId,
|
||||||
|
userId=userId,
|
||||||
|
accountType=AccountTypeEnum.USER,
|
||||||
|
balance=defaultCredit,
|
||||||
|
enabled=True
|
||||||
|
)
|
||||||
|
created = self.createAccount(account)
|
||||||
|
|
||||||
|
# Create initial credit transaction
|
||||||
|
if defaultCredit > 0:
|
||||||
|
self.createTransaction(BillingTransaction(
|
||||||
|
accountId=created["id"],
|
||||||
|
transactionType=TransactionTypeEnum.CREDIT,
|
||||||
|
amount=defaultCredit,
|
||||||
|
description="Initial credit for new user",
|
||||||
|
referenceType=ReferenceTypeEnum.SYSTEM
|
||||||
|
))
|
||||||
|
|
||||||
|
existingAccountKeys.add(key) # Track newly created
|
||||||
|
accountsCreated += 1
|
||||||
|
|
||||||
|
if accountsCreated > 0:
|
||||||
|
logger.info(f"Created {accountsCreated} missing billing accounts")
|
||||||
|
|
||||||
|
return accountsCreated
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error ensuring user accounts exist: {e}")
|
||||||
|
return 0
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# BillingTransaction Operations
|
# BillingTransaction Operations
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
@ -502,11 +594,16 @@ class BillingObjects:
|
||||||
# Get the relevant account
|
# Get the relevant account
|
||||||
if billingModel == BillingModelEnum.PREPAY_USER:
|
if billingModel == BillingModelEnum.PREPAY_USER:
|
||||||
account = self.getUserAccount(mandateId, userId)
|
account = self.getUserAccount(mandateId, userId)
|
||||||
|
# Auto-create user account if not exists (with default credit from settings)
|
||||||
|
if not account:
|
||||||
|
defaultCredit = settings.get("defaultUserCredit", 10.0)
|
||||||
|
logger.info(f"Auto-creating billing account for user {userId} in mandate {mandateId} with {defaultCredit} CHF initial credit")
|
||||||
|
account = self.getOrCreateUserAccount(mandateId, userId, initialBalance=defaultCredit)
|
||||||
else:
|
else:
|
||||||
account = self.getMandateAccount(mandateId)
|
account = self.getMandateAccount(mandateId)
|
||||||
|
|
||||||
if not account:
|
if not account:
|
||||||
# No account = no balance = potentially blocked
|
# No account (only happens for mandate-level accounts) = potentially blocked
|
||||||
if settings.get("blockOnZeroBalance", True):
|
if settings.get("blockOnZeroBalance", True):
|
||||||
return BillingCheckResult(
|
return BillingCheckResult(
|
||||||
allowed=False,
|
allowed=False,
|
||||||
|
|
@ -713,11 +810,18 @@ class BillingObjects:
|
||||||
userMandates = appInterface.getUserMandates(userId)
|
userMandates = appInterface.getUserMandates(userId)
|
||||||
|
|
||||||
for um in userMandates:
|
for um in userMandates:
|
||||||
mandateId = um.get("mandateId")
|
# Handle both Pydantic models and dicts
|
||||||
|
mandateId = getattr(um, 'mandateId', None) or (um.get("mandateId") if isinstance(um, dict) else None)
|
||||||
|
if not mandateId:
|
||||||
|
continue
|
||||||
|
|
||||||
mandate = appInterface.getMandate(mandateId)
|
mandate = appInterface.getMandate(mandateId)
|
||||||
if not mandate:
|
if not mandate:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Get mandate name (handle both Pydantic and dict)
|
||||||
|
mandateName = getattr(mandate, 'name', None) or (mandate.get("name", "") if isinstance(mandate, dict) else "")
|
||||||
|
|
||||||
settings = self.getSettings(mandateId)
|
settings = self.getSettings(mandateId)
|
||||||
if not settings:
|
if not settings:
|
||||||
continue
|
continue
|
||||||
|
|
@ -740,7 +844,7 @@ class BillingObjects:
|
||||||
|
|
||||||
balances.append(BillingBalanceResponse(
|
balances.append(BillingBalanceResponse(
|
||||||
mandateId=mandateId,
|
mandateId=mandateId,
|
||||||
mandateName=mandate.get("name", ""),
|
mandateName=mandateName,
|
||||||
billingModel=billingModel,
|
billingModel=billingModel,
|
||||||
balance=balance,
|
balance=balance,
|
||||||
warningThreshold=warningThreshold,
|
warningThreshold=warningThreshold,
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ async def getBalance(
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
billingService = getBillingService(
|
billingService = getBillingService(
|
||||||
ctx.currentUser,
|
ctx.user,
|
||||||
ctx.mandateId,
|
ctx.mandateId,
|
||||||
featureCode="billing"
|
featureCode="billing"
|
||||||
)
|
)
|
||||||
|
|
@ -148,7 +148,7 @@ async def getBalanceForMandate(
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
billingService = getBillingService(
|
billingService = getBillingService(
|
||||||
ctx.currentUser,
|
ctx.user,
|
||||||
targetMandateId,
|
targetMandateId,
|
||||||
featureCode="billing"
|
featureCode="billing"
|
||||||
)
|
)
|
||||||
|
|
@ -158,7 +158,7 @@ async def getBalanceForMandate(
|
||||||
|
|
||||||
# Get mandate name from app interface
|
# Get mandate name from app interface
|
||||||
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
|
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
|
||||||
appInterface = getAppInterface(ctx.currentUser, mandateId=targetMandateId)
|
appInterface = getAppInterface(ctx.user, mandateId=targetMandateId)
|
||||||
mandate = appInterface.getMandate(targetMandateId)
|
mandate = appInterface.getMandate(targetMandateId)
|
||||||
mandateName = mandate.get("name", "") if mandate else ""
|
mandateName = mandate.get("name", "") if mandate else ""
|
||||||
|
|
||||||
|
|
@ -190,7 +190,7 @@ async def getTransactions(
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
billingService = getBillingService(
|
billingService = getBillingService(
|
||||||
ctx.currentUser,
|
ctx.user,
|
||||||
ctx.mandateId,
|
ctx.mandateId,
|
||||||
featureCode="billing"
|
featureCode="billing"
|
||||||
)
|
)
|
||||||
|
|
@ -240,7 +240,7 @@ async def getStatistics(
|
||||||
if period == "day" and not month:
|
if period == "day" and not month:
|
||||||
raise HTTPException(status_code=400, detail="Month is required for 'day' period")
|
raise HTTPException(status_code=400, detail="Month is required for 'day' period")
|
||||||
|
|
||||||
billingInterface = getBillingInterface(ctx.currentUser, ctx.mandateId)
|
billingInterface = getBillingInterface(ctx.user, ctx.mandateId)
|
||||||
settings = billingInterface.getSettings(ctx.mandateId)
|
settings = billingInterface.getSettings(ctx.mandateId)
|
||||||
|
|
||||||
if not settings:
|
if not settings:
|
||||||
|
|
@ -256,7 +256,7 @@ async def getStatistics(
|
||||||
|
|
||||||
# Get the relevant account
|
# Get the relevant account
|
||||||
if billingModel == BillingModelEnum.PREPAY_USER:
|
if billingModel == BillingModelEnum.PREPAY_USER:
|
||||||
account = billingInterface.getUserAccount(ctx.mandateId, ctx.currentUser.id)
|
account = billingInterface.getUserAccount(ctx.mandateId, ctx.user.id)
|
||||||
else:
|
else:
|
||||||
account = billingInterface.getMandateAccount(ctx.mandateId)
|
account = billingInterface.getMandateAccount(ctx.mandateId)
|
||||||
|
|
||||||
|
|
@ -316,7 +316,7 @@ async def getAllowedProviders(
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
billingService = getBillingService(
|
billingService = getBillingService(
|
||||||
ctx.currentUser,
|
ctx.user,
|
||||||
ctx.mandateId,
|
ctx.mandateId,
|
||||||
featureCode="billing"
|
featureCode="billing"
|
||||||
)
|
)
|
||||||
|
|
@ -344,7 +344,7 @@ async def getSettingsAdmin(
|
||||||
Get billing settings for a mandate (SysAdmin only).
|
Get billing settings for a mandate (SysAdmin only).
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
billingInterface = getBillingInterface(ctx.currentUser, targetMandateId)
|
billingInterface = getBillingInterface(ctx.user, targetMandateId)
|
||||||
settings = billingInterface.getSettings(targetMandateId)
|
settings = billingInterface.getSettings(targetMandateId)
|
||||||
|
|
||||||
if not settings:
|
if not settings:
|
||||||
|
|
@ -372,7 +372,7 @@ async def createOrUpdateSettings(
|
||||||
Create or update billing settings for a mandate (SysAdmin only).
|
Create or update billing settings for a mandate (SysAdmin only).
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
billingInterface = getBillingInterface(ctx.currentUser, targetMandateId)
|
billingInterface = getBillingInterface(ctx.user, targetMandateId)
|
||||||
existingSettings = billingInterface.getSettings(targetMandateId)
|
existingSettings = billingInterface.getSettings(targetMandateId)
|
||||||
|
|
||||||
if existingSettings:
|
if existingSettings:
|
||||||
|
|
@ -421,7 +421,7 @@ async def addCredit(
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Get settings to determine billing model
|
# Get settings to determine billing model
|
||||||
billingInterface = getBillingInterface(ctx.currentUser, targetMandateId)
|
billingInterface = getBillingInterface(ctx.user, targetMandateId)
|
||||||
settings = billingInterface.getSettings(targetMandateId)
|
settings = billingInterface.getSettings(targetMandateId)
|
||||||
|
|
||||||
if not settings:
|
if not settings:
|
||||||
|
|
@ -482,7 +482,7 @@ async def getAccounts(
|
||||||
Get all billing accounts for a mandate (SysAdmin only).
|
Get all billing accounts for a mandate (SysAdmin only).
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
billingInterface = getBillingInterface(ctx.currentUser, targetMandateId)
|
billingInterface = getBillingInterface(ctx.user, targetMandateId)
|
||||||
|
|
||||||
# Get all accounts for this mandate via interface
|
# Get all accounts for this mandate via interface
|
||||||
accounts = billingInterface.getAccountsByMandate(targetMandateId)
|
accounts = billingInterface.getAccountsByMandate(targetMandateId)
|
||||||
|
|
@ -507,6 +507,70 @@ async def getAccounts(
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
class MandateUserSummary(BaseModel):
|
||||||
|
"""Summary of a user for billing admin purposes."""
|
||||||
|
id: str
|
||||||
|
email: Optional[str] = None
|
||||||
|
firstName: Optional[str] = None
|
||||||
|
lastName: Optional[str] = None
|
||||||
|
displayName: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/users/{targetMandateId}", response_model=List[MandateUserSummary])
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
async def getUsersForMandate(
|
||||||
|
request: Request,
|
||||||
|
targetMandateId: str = Path(..., description="Mandate ID"),
|
||||||
|
ctx: RequestContext = Depends(getRequestContext),
|
||||||
|
_admin = Depends(requireSysAdmin)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get all users belonging to a mandate (SysAdmin only).
|
||||||
|
Used by billing admin to select users for credit assignment.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
|
||||||
|
|
||||||
|
appInterface = getAppInterface(ctx.user, mandateId=targetMandateId)
|
||||||
|
userMandates = appInterface.getUserMandatesByMandate(targetMandateId)
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for um in userMandates:
|
||||||
|
userId = um.get("userId") if isinstance(um, dict) else getattr(um, "userId", None)
|
||||||
|
if not userId:
|
||||||
|
continue
|
||||||
|
|
||||||
|
user = appInterface.getUser(userId)
|
||||||
|
if not user:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Handle both Pydantic models and dicts
|
||||||
|
if isinstance(user, dict):
|
||||||
|
firstName = user.get("firstName", "")
|
||||||
|
lastName = user.get("lastName", "")
|
||||||
|
email = user.get("email", "")
|
||||||
|
else:
|
||||||
|
firstName = getattr(user, "firstName", "") or ""
|
||||||
|
lastName = getattr(user, "lastName", "") or ""
|
||||||
|
email = getattr(user, "email", "") or ""
|
||||||
|
|
||||||
|
displayName = f"{firstName} {lastName}".strip() or email or userId
|
||||||
|
|
||||||
|
result.append(MandateUserSummary(
|
||||||
|
id=userId,
|
||||||
|
email=email,
|
||||||
|
firstName=firstName,
|
||||||
|
lastName=lastName,
|
||||||
|
displayName=displayName
|
||||||
|
))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting users for mandate {targetMandateId}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/admin/transactions/{targetMandateId}", response_model=List[TransactionResponse])
|
@router.get("/admin/transactions/{targetMandateId}", response_model=List[TransactionResponse])
|
||||||
@limiter.limit("30/minute")
|
@limiter.limit("30/minute")
|
||||||
async def getTransactionsAdmin(
|
async def getTransactionsAdmin(
|
||||||
|
|
@ -520,7 +584,7 @@ async def getTransactionsAdmin(
|
||||||
Get all transactions for a mandate (SysAdmin only).
|
Get all transactions for a mandate (SysAdmin only).
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
billingInterface = getBillingInterface(ctx.currentUser, targetMandateId)
|
billingInterface = getBillingInterface(ctx.user, targetMandateId)
|
||||||
transactions = billingInterface.getTransactionsByMandate(targetMandateId, limit=limit)
|
transactions = billingInterface.getTransactionsByMandate(targetMandateId, limit=limit)
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
|
|
|
||||||
|
|
@ -184,16 +184,17 @@ class AiService:
|
||||||
)
|
)
|
||||||
logger.debug(f"Automation provider check passed: {effectiveProviders}")
|
logger.debug(f"Automation provider check passed: {effectiveProviders}")
|
||||||
|
|
||||||
# Check if preferred provider (from UI selection) is allowed
|
# Check if preferred providers (from UI multiselect) are allowed
|
||||||
preferredProvider = getattr(self.services, 'preferredProvider', None)
|
preferredProviders = getattr(self.services, 'preferredProviders', None)
|
||||||
if preferredProvider:
|
if preferredProviders:
|
||||||
if preferredProvider not in rbacAllowedProviders:
|
for provider in preferredProviders:
|
||||||
logger.warning(f"Preferred provider {preferredProvider} not allowed for user {user.id}")
|
if provider not in rbacAllowedProviders:
|
||||||
raise ProviderNotAllowedException(
|
logger.warning(f"Preferred provider {provider} not allowed for user {user.id}")
|
||||||
provider=preferredProvider,
|
raise ProviderNotAllowedException(
|
||||||
message=f"Der gewählte Provider '{preferredProvider}' ist für Ihre Rolle nicht freigegeben."
|
provider=provider,
|
||||||
)
|
message=f"Der gewählte Provider '{provider}' ist für Ihre Rolle nicht freigegeben."
|
||||||
logger.debug(f"Preferred provider {preferredProvider} is allowed")
|
)
|
||||||
|
logger.debug(f"All preferred providers are allowed: {preferredProviders}")
|
||||||
|
|
||||||
logger.debug(f"Provider check passed: {len(rbacAllowedProviders)} providers allowed")
|
logger.debug(f"Provider check passed: {len(rbacAllowedProviders)} providers allowed")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,10 +42,14 @@ async def chatStart(currentUser: User, userInput: UserInputRequest, workflowMode
|
||||||
try:
|
try:
|
||||||
services = getServices(currentUser, mandateId=mandateId)
|
services = getServices(currentUser, mandateId=mandateId)
|
||||||
|
|
||||||
# Store preferred provider in services context for billing/model selection
|
# Store preferred providers in services context for billing/model selection
|
||||||
if hasattr(userInput, 'preferredProvider') and userInput.preferredProvider:
|
# Support both preferredProviders (list) and legacy preferredProvider (string)
|
||||||
services.preferredProvider = userInput.preferredProvider
|
if hasattr(userInput, 'preferredProviders') and userInput.preferredProviders:
|
||||||
logger.debug(f"Using preferred provider: {userInput.preferredProvider}")
|
services.preferredProviders = userInput.preferredProviders
|
||||||
|
logger.debug(f"Using preferred providers: {userInput.preferredProviders}")
|
||||||
|
elif hasattr(userInput, 'preferredProvider') and userInput.preferredProvider:
|
||||||
|
services.preferredProviders = [userInput.preferredProvider]
|
||||||
|
logger.debug(f"Using preferred provider (legacy): {userInput.preferredProvider}")
|
||||||
|
|
||||||
# Store feature instance ID in services context
|
# Store feature instance ID in services context
|
||||||
if featureInstanceId:
|
if featureInstanceId:
|
||||||
|
|
@ -59,10 +63,14 @@ async def chatStart(currentUser: User, userInput: UserInputRequest, workflowMode
|
||||||
logger.error(f"Error starting chat: {str(e)}")
|
logger.error(f"Error starting chat: {str(e)}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def chatStop(currentUser: User, workflowId: str, mandateId: Optional[str] = None) -> ChatWorkflow:
|
async def chatStop(currentUser: User, workflowId: str, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> ChatWorkflow:
|
||||||
"""Stops a running chat."""
|
"""Stops a running chat."""
|
||||||
try:
|
try:
|
||||||
services = getServices(currentUser, mandateId=mandateId)
|
services = getServices(currentUser, mandateId=mandateId)
|
||||||
|
# Store feature instance ID in services context for proper RBAC filtering
|
||||||
|
if featureInstanceId:
|
||||||
|
services.featureInstanceId = featureInstanceId
|
||||||
|
services.featureCode = 'chatplayground'
|
||||||
workflowManager = WorkflowManager(services)
|
workflowManager = WorkflowManager(services)
|
||||||
return await workflowManager.workflowStop(workflowId)
|
return await workflowManager.workflowStop(workflowId)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,7 @@ class WorkflowManager:
|
||||||
"totalTasks": 0,
|
"totalTasks": 0,
|
||||||
"totalActions": 0,
|
"totalActions": 0,
|
||||||
"mandateId": self.services.mandateId,
|
"mandateId": self.services.mandateId,
|
||||||
|
"featureInstanceId": getattr(self.services, 'featureInstanceId', None), # Feature instance ID for isolation
|
||||||
"messageIds": [],
|
"messageIds": [],
|
||||||
"workflowMode": workflowMode,
|
"workflowMode": workflowMode,
|
||||||
"maxSteps": 10 , # Set maxSteps
|
"maxSteps": 10 , # Set maxSteps
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue