streamlined billing incl ai and storage budget

This commit is contained in:
ValueOn AG 2026-03-29 12:18:58 +02:00
parent c12a75f87f
commit 3ac25a269a
21 changed files with 740 additions and 175 deletions

3
app.py
View file

@ -566,6 +566,9 @@ app.include_router(googleRouter)
from modules.routes.routeVoiceGoogle import router as voiceGoogleRouter
app.include_router(voiceGoogleRouter)
from modules.routes.routeVoiceUser import router as voiceUserRouter
app.include_router(voiceUserRouter)
from modules.routes.routeSecurityAdmin import router as adminSecurityRouter
app.include_router(adminSecurityRouter)

View file

@ -158,12 +158,17 @@ def _parseRecordFields(record: Dict[str, Any], fields: Dict[str, str], context:
logger.warning(f"Could not parse JSONB field {fieldName}, keeping as string ({context})")
# Legacy system columns (underscore-prefixed internal names) -> PowerOn sys* columns.
_LEGACY_UNDERSCORE_TO_SYS: Tuple[Tuple[str, str], ...] = (
# Legacy column names (historical _* internal names and old camelCase audit fields) -> PowerOn sys* columns.
# Order matters: more specific / underscore names first; first successful copy wins per cell via IS NULL on target.
_LEGACY_FIELD_TO_SYS: Tuple[Tuple[str, str], ...] = (
("_createdAt", "sysCreatedAt"),
("_createdBy", "sysCreatedBy"),
("_modifiedAt", "sysModifiedAt"),
("_modifiedBy", "sysModifiedBy"),
("createdAt", "sysCreatedAt"),
("creationDate", "sysCreatedAt"),
("updatedAt", "sysModifiedAt"),
("lastModified", "sysModifiedAt"),
)
@ -454,9 +459,9 @@ class DatabaseConnector:
def migrateLegacyUnderscoreSysColumns(self) -> int:
"""
Scan all public base tables on this connection's database. Where both a legacy
_createdAt / _createdBy / _modifiedAt / _modifiedBy column (any case) and the
matching sys* column exist, copy into sys* rows where sys* IS NULL and legacy IS NOT NULL.
Idempotent; safe to run on every bootstrap.
source column (any case: _createdAt, createdAt, creationDate, ) and the matching
sys* column exist, UPDATE sys* from legacy where sys* IS NULL AND legacy IS NOT NULL.
Idempotent; run after schema adds sys* columns (see _ensureTableExists).
"""
self._ensure_connection()
total = 0
@ -466,7 +471,7 @@ class DatabaseConnector:
for table in tableNames:
with self.connection.cursor() as cursor:
cols = _listTableColumnNames(cursor, table)
for legacyLogical, sysLogical in _LEGACY_UNDERSCORE_TO_SYS:
for legacyLogical, sysLogical in _LEGACY_FIELD_TO_SYS:
src = _resolveColumnCaseInsensitive(cols, legacyLogical)
tgt = _resolveColumnCaseInsensitive(cols, sysLogical)
if not src or not tgt or src == tgt:
@ -629,6 +634,7 @@ class DatabaseConnector:
try:
self._ensure_connection()
schemaTouched = False
with self.connection.cursor() as cursor:
# Check if table exists by querying information_schema with case-insensitive search
@ -647,6 +653,7 @@ class DatabaseConnector:
logger.info(
f"Created table '{table}' with columns from Pydantic model"
)
schemaTouched = True
else:
# Table exists: ensure all columns from model are present (simple additive migration)
try:
@ -680,6 +687,7 @@ class DatabaseConnector:
logger.info(
f"Added missing column '{col}' ({sql_type}) to '{table}'"
)
schemaTouched = True
except Exception as add_err:
logger.warning(
f"Could not add column '{col}' to '{table}': {add_err}"
@ -690,6 +698,23 @@ class DatabaseConnector:
)
self.connection.commit()
if schemaTouched:
try:
n = self.migrateLegacyUnderscoreSysColumns()
if n:
logger.info(
"After schema change on %s.%s: legacy -> sys* migration wrote %s cell(s)",
self.dbDatabase,
table,
n,
)
except Exception as mig_err:
logger.error(
"migrateLegacyUnderscoreSysColumns failed after schema change %s.%s: %s",
self.dbDatabase,
table,
mig_err,
)
return True
except Exception as e:
logger.error(f"Error ensuring table {table} exists: {e}")

View file

@ -18,6 +18,11 @@ from modules.shared.configuration import APP_CONFIG
logger = logging.getLogger(__name__)
# Gemini-TTS speaker IDs from voices.list use short names (e.g. "Kore") and require model_name + prompt.
_GEMINI_TTS_DEFAULT_MODEL = "gemini-2.5-flash-tts"
_GEMINI_TTS_NEUTRAL_PROMPT = "Say the following"
class ConnectorGoogleSpeech:
"""
Google Cloud Speech-to-Text and Translation connector.
@ -902,6 +907,13 @@ class ConnectorGoogleSpeech:
"error": f"Validation error: {e}"
}
def _isGeminiTtsSpeakerVoiceName(self, voiceName: str) -> bool:
"""True when voice name is a Gemini-TTS speaker id (no BCP-47 prefix like en-US-...)."""
if not voiceName or not isinstance(voiceName, str):
return False
stripped = voiceName.strip()
return bool(stripped) and "-" not in stripped
async def textToSpeech(self, text: str, languageCode: str = "de-DE", voiceName: str = None) -> Dict[str, Any]:
"""
Convert text to speech using Google Cloud Text-to-Speech.
@ -917,9 +929,6 @@ class ConnectorGoogleSpeech:
try:
logger.info(f"Converting text to speech: '{text[:50]}...' in {languageCode}")
# Set up the synthesis input
synthesisInput = texttospeech.SynthesisInput(text=text)
# Build the voice request
selectedVoice = voiceName or self._getDefaultVoice(languageCode)
@ -931,11 +940,24 @@ class ConnectorGoogleSpeech:
logger.info(f"Using TTS voice: {selectedVoice} for language: {languageCode}")
voice = texttospeech.VoiceSelectionParams(
language_code=languageCode,
name=selectedVoice,
ssml_gender=texttospeech.SsmlVoiceGender.NEUTRAL
)
if self._isGeminiTtsSpeakerVoiceName(selectedVoice):
synthesisInput = texttospeech.SynthesisInput(
text=text,
prompt=_GEMINI_TTS_NEUTRAL_PROMPT,
)
voice = texttospeech.VoiceSelectionParams(
language_code=languageCode,
name=selectedVoice,
model_name=_GEMINI_TTS_DEFAULT_MODEL,
ssml_gender=texttospeech.SsmlVoiceGender.NEUTRAL,
)
else:
synthesisInput = texttospeech.SynthesisInput(text=text)
voice = texttospeech.VoiceSelectionParams(
language_code=languageCode,
name=selectedVoice,
ssml_gender=texttospeech.SsmlVoiceGender.NEUTRAL,
)
# Select the type of audio file to return
audioConfig = texttospeech.AudioConfig(
@ -1059,7 +1081,8 @@ class ConnectorGoogleSpeech:
"language_codes": list(voice.language_codes) if voice.language_codes else [],
"gender": gender,
"ssml_gender": voice.ssml_gender.name if voice.ssml_gender else "NEUTRAL",
"natural_sample_rate_hertz": voice.natural_sample_rate_hertz
"natural_sample_rate_hertz": voice.natural_sample_rate_hertz,
"geminiTts": self._isGeminiTtsSpeakerVoiceName(voice.name or ""),
}
# Include any additional fields if available from Google API

View file

@ -10,6 +10,9 @@ from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels
import uuid
# End-customer price for storage above plan-included volume (CHF per GB per month).
STORAGE_PRICE_PER_GB_CHF = 0.50
class TransactionTypeEnum(str, Enum):
"""Transaction types for billing."""
@ -24,6 +27,7 @@ class ReferenceTypeEnum(str, Enum):
PAYMENT = "PAYMENT" # Payment/top-up
ADMIN = "ADMIN" # Admin adjustment
SYSTEM = "SYSTEM" # System credit (e.g., initial credit)
STORAGE = "STORAGE" # Metered storage overage (prepay pool)
class PeriodTypeEnum(str, Enum):
@ -137,6 +141,18 @@ class BillingSettings(BaseModel):
)
notifyOnWarning: bool = Field(default=True, description="Send email when warning threshold is reached")
# Storage overage (high-watermark within subscription period; resets on new period)
storageHighWatermarkMB: float = Field(
default=0.0, description="Peak indexed data volume MB this billing period"
)
storagePeriodStartAt: Optional[datetime] = Field(
None, description="Subscription billing period start used for storage reset"
)
storageBilledUpToMB: float = Field(
default=0.0,
description="Overage MB already debited this period (above plan-included volume)",
)
registerModelLabels(
"BillingSettings",
@ -154,6 +170,12 @@ registerModelLabels(
"de": "E-Mails fuer Billing-Alerts (Inhaber/Admin)",
},
"notifyOnWarning": {"en": "Notify on Warning", "de": "Bei Warnung benachrichtigen"},
"storageHighWatermarkMB": {"en": "Storage peak (MB)", "de": "Speicher-Peak (MB)"},
"storagePeriodStartAt": {"en": "Storage period start", "de": "Speicher-Periodenbeginn"},
"storageBilledUpToMB": {
"en": "Storage billed overage (MB)",
"de": "Speicher abgerechneter Überhang (MB)",
},
},
)

View file

@ -218,7 +218,7 @@ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
billingPeriod=BillingPeriodEnum.MONTHLY,
pricePerUserCHF=90.0,
pricePerFeatureInstanceCHF=150.0,
maxDataVolumeMB=10240,
maxDataVolumeMB=1024,
budgetAiCHF=10.0,
),
"STANDARD_YEARLY": SubscriptionPlan(
@ -232,7 +232,7 @@ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
billingPeriod=BillingPeriodEnum.YEARLY,
pricePerUserCHF=1080.0,
pricePerFeatureInstanceCHF=1800.0,
maxDataVolumeMB=10240,
maxDataVolumeMB=1024,
budgetAiCHF=120.0,
),
}

View file

@ -76,6 +76,27 @@ class _PendingEditsStore:
_pendingEditsStore = _PendingEditsStore()
def _workspaceBillingFeatureCode(user, mandateId: Optional[str], instanceId: str) -> Optional[str]:
"""Resolve FeatureInstance.featureCode for billing/UI when workflow is not on ServiceCenterContext."""
if not instanceId or not str(instanceId).strip():
return None
try:
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
appIf = getAppInterface(user, mandateId=mandateId or None)
inst = appIf.getFeatureInstance(str(instanceId).strip())
if not inst:
return None
if isinstance(inst, dict):
code = inst.get("featureCode")
else:
code = getattr(inst, "featureCode", None)
return str(code).strip() if code else None
except Exception as e:
logger.debug("Workspace: feature code lookup failed for instance %s: %s", instanceId, e)
return None
class WorkspaceInputRequest(BaseModel):
"""Prompt input for the unified workspace."""
prompt: str = Field(description="User prompt text")
@ -546,11 +567,13 @@ async def streamWorkspaceStart(
from modules.serviceCenter import getService
from modules.serviceCenter.context import ServiceCenterContext
wsBillingFeatureCode = _workspaceBillingFeatureCode(context.user, mandateId or "", instanceId)
svcCtx = ServiceCenterContext(
user=context.user,
mandate_id=mandateId or "",
feature_instance_id=instanceId,
workflow_id=workflowId,
feature_code=wsBillingFeatureCode,
)
chatSvc = getService("chat", svcCtx)
attachmentLabel = _buildWorkspaceAttachmentLabel(
@ -590,6 +613,7 @@ async def streamWorkspaceStart(
instanceConfig=instanceConfig,
allowedProviders=userInput.allowedProviders,
requireNeutralization=userInput.requireNeutralization,
billingFeatureCode=wsBillingFeatureCode,
)
)
eventManager.register_agent_task(queueId, agentTask)
@ -646,6 +670,7 @@ async def _runWorkspaceAgent(
instanceConfig: Dict[str, Any] = None,
allowedProviders: List[str] = None,
requireNeutralization: Optional[bool] = None,
billingFeatureCode: Optional[str] = None,
):
"""Run the serviceAgent loop and forward events to the SSE queue."""
try:
@ -656,6 +681,7 @@ async def _runWorkspaceAgent(
mandate_id=mandateId,
feature_instance_id=instanceId,
workflow_id=workflowId,
feature_code=billingFeatureCode,
)
agentService = getService("agent", ctx)
chatService = getService("chat", ctx)

View file

@ -38,15 +38,23 @@ pwdContext = CryptContext(schemes=["argon2"], deprecated="auto")
# Cache für Role-IDs (roleLabel -> roleId)
_roleIdCache: Dict[str, str] = {}
# PowerOn logical databases to scan (same set as gateway/scripts/script_db_export_migration.py).
# PowerOn logical databases to scan (same set as gateway/scripts/script_db_export_migration.py ALL_DATABASES).
_POWERON_DATABASE_NAMES: Tuple[str, ...] = (
"poweron_app",
"poweron_automation",
"poweron_automation2",
"poweron_billing",
"poweron_chat",
"poweron_chatbot",
"poweron_commcoach",
"poweron_knowledge",
"poweron_management",
"poweron_neutralization",
"poweron_realestate",
"poweron_teamsbot",
"poweron_test",
"poweron_trustee",
"poweron_automation",
"poweron_workspace",
)
@ -60,6 +68,7 @@ def _configPrefixForPoweronDatabase(dbName: str) -> str:
"poweron_trustee": "DB_TRUSTEE",
# Same as initAutomationTemplates: default DB_* (not a separate DB_AUTOMATION_* prefix).
"poweron_automation": "DB",
"poweron_billing": "DB",
}.get(dbName, "DB")

View file

@ -9,7 +9,7 @@ All billing data is stored in the poweron_billing database.
import logging
from typing import Dict, Any, List, Optional, Union
from datetime import date, datetime, timedelta
from datetime import date, datetime, timedelta, timezone
import uuid
from modules.connectors.connectorDbPostgre import DatabaseConnector
@ -29,11 +29,44 @@ from modules.datamodels.datamodelBilling import (
PeriodTypeEnum,
BillingBalanceResponse,
BillingCheckResult,
STORAGE_PRICE_PER_GB_CHF,
)
logger = logging.getLogger(__name__)
def _logBillingTransactionsMissingSysCreatedAt(rows: List[Dict[str, Any]], context: str) -> None:
"""Log ERROR when sysCreatedAt is missing; does not raise."""
missingIds = [r.get("id") for r in rows if r.get("sysCreatedAt") is None]
if not missingIds:
return
cap = 40
sample = missingIds[:cap]
suffix = f"; ... (+{len(missingIds) - cap} more)" if len(missingIds) > cap else ""
logger.error(
"BillingTransaction missing sysCreatedAt (%s): count=%s; transactionIds=%s%s",
context,
len(missingIds),
sample,
suffix,
)
def _numericSysCreatedAtForSort(row: Dict[str, Any]) -> float:
v = row["sysCreatedAt"]
if isinstance(v, datetime):
return v.timestamp()
return float(v)
def _sortBillingTransactionsBySysCreatedAtDesc(rows: List[Dict[str, Any]], context: str) -> None:
_logBillingTransactionsMissingSysCreatedAt(rows, context)
valid = [r for r in rows if r.get("sysCreatedAt") is not None]
invalid = [r for r in rows if r.get("sysCreatedAt") is None]
valid.sort(key=_numericSysCreatedAtForSort, reverse=True)
rows[:] = valid + invalid
def _getAppDatabaseConnector() -> DatabaseConnector:
"""App DB connector (same config as UserMandate reads in this module)."""
return DatabaseConnector(
@ -553,6 +586,17 @@ class BillingObjects:
# Create transaction record (always on transaction.accountId for audit)
transactionDict = transaction.model_dump(exclude_none=True)
ts = getUtcTimestamp()
uid = str(self.userId) if self.userId else None
if transactionDict.get("sysCreatedAt") is None:
transactionDict["sysCreatedAt"] = ts
if transactionDict.get("sysModifiedAt") is None:
transactionDict["sysModifiedAt"] = ts
if uid:
if transactionDict.get("sysCreatedBy") is None:
transactionDict["sysCreatedBy"] = uid
if transactionDict.get("sysModifiedBy") is None:
transactionDict["sysModifiedBy"] = uid
created = self.db.recordCreate(BillingTransaction, transactionDict)
# Update balance on the target account
@ -597,6 +641,10 @@ class BillingObjects:
pagination=pagination,
recordFilter=recordFilter
)
_logBillingTransactionsMissingSysCreatedAt(
result["items"],
"getTransactions(accountId) paginated",
)
return PaginatedResult(
items=result["items"],
totalItems=result["totalItems"],
@ -619,7 +667,7 @@ class BillingObjects:
filtered.append(t)
results = filtered
results.sort(key=lambda x: x.get("sysCreatedAt", ""), reverse=True)
_sortBillingTransactionsBySysCreatedAtDesc(results, "getTransactions(accountId)")
return results[offset:offset + limit]
except Exception as e:
@ -674,7 +722,10 @@ class BillingObjects:
transactions = self.getTransactions(account["id"], limit=limit)
allTransactions.extend(transactions)
allTransactions.sort(key=lambda x: x.get("sysCreatedAt", ""), reverse=True)
_sortBillingTransactionsBySysCreatedAtDesc(
allTransactions,
"getTransactionsByMandate",
)
return allTransactions[:limit]
# =========================================================================
@ -816,11 +867,113 @@ class BillingObjects:
poolAccount = self.getOrCreateMandateAccount(mandateId)
return self.createTransaction(transaction, balanceAccountId=poolAccount["id"])
def _parseSettingsDateTime(self, value: Any) -> Optional[datetime]:
"""Parse datetime from billing settings row (ISO string or datetime)."""
if value is None:
return None
if isinstance(value, datetime):
if value.tzinfo:
return value.astimezone(timezone.utc)
return value.replace(tzinfo=timezone.utc)
if isinstance(value, str):
s = value.replace("Z", "+00:00")
try:
dt = datetime.fromisoformat(s)
except ValueError:
return None
if dt.tzinfo:
return dt.astimezone(timezone.utc)
return dt.replace(tzinfo=timezone.utc)
return None
def resetStorageBillingPeriod(self, mandateId: str, periodStartAt: datetime) -> None:
"""Reset storage watermark state for a new subscription billing period (e.g. Stripe invoice.paid)."""
if periodStartAt.tzinfo is None:
periodStartAt = periodStartAt.replace(tzinfo=timezone.utc)
else:
periodStartAt = periodStartAt.astimezone(timezone.utc)
settings = self.getOrCreateSettings(mandateId)
prev = self._parseSettingsDateTime(settings.get("storagePeriodStartAt"))
if prev is not None and abs((prev - periodStartAt).total_seconds()) < 2:
return
from modules.interfaces.interfaceDbSubscription import _getRootInterface as _getSubRoot
usedMB = float(_getSubRoot().getMandateDataVolumeMB(mandateId))
self.updateSettings(
settings["id"],
{
"storageHighWatermarkMB": usedMB,
"storageBilledUpToMB": 0.0,
"storagePeriodStartAt": periodStartAt,
},
)
logger.info(
"Storage billing period reset for mandate %s at %s (usedMB=%.2f)",
mandateId,
periodStartAt.isoformat(),
usedMB,
)
def reconcileMandateStorageBilling(self, mandateId: str) -> Optional[Dict[str, Any]]:
"""Debit prepay pool for new storage overage using period high-watermark (no credit on delete)."""
settings = self.getSettings(mandateId)
if not settings:
return None
from modules.interfaces.interfaceDbSubscription import _getRootInterface as _getSubRoot
from modules.datamodels.datamodelSubscription import _getPlan
subIface = _getSubRoot()
usedMB = float(subIface.getMandateDataVolumeMB(mandateId))
sub = subIface.getOperativeForMandate(mandateId)
plan = _getPlan(sub.get("planKey", "")) if sub else None
includedMB = plan.maxDataVolumeMB if plan and plan.maxDataVolumeMB is not None else None
if includedMB is None:
return None
prevHigh = float(settings.get("storageHighWatermarkMB") or 0.0)
high = max(prevHigh, usedMB)
overageMB = max(0.0, high - float(includedMB))
billed = float(settings.get("storageBilledUpToMB") or 0.0)
deltaOverage = overageMB - billed
settingsUpdates: Dict[str, Any] = {}
if high != prevHigh:
settingsUpdates["storageHighWatermarkMB"] = high
if deltaOverage <= 1e-9:
if settingsUpdates:
self.updateSettings(settings["id"], settingsUpdates)
return None
costCHF = round((deltaOverage / 1024.0) * float(STORAGE_PRICE_PER_GB_CHF), 4)
if costCHF <= 0:
if settingsUpdates:
self.updateSettings(settings["id"], settingsUpdates)
return None
poolAccount = self.getOrCreateMandateAccount(mandateId)
transaction = BillingTransaction(
accountId=poolAccount["id"],
transactionType=TransactionTypeEnum.DEBIT,
amount=costCHF,
description=f"Speicher-Überhang ({deltaOverage:.2f} MB über Plan)",
referenceType=ReferenceTypeEnum.STORAGE,
referenceId=mandateId,
)
created = self.createTransaction(transaction)
settingsUpdates["storageBilledUpToMB"] = overageMB
self.updateSettings(settings["id"], settingsUpdates)
logger.info(
"Storage overage billed mandate=%s deltaOverageMB=%.4f costCHF=%s",
mandateId,
deltaOverage,
costCHF,
)
return created
# =========================================================================
# Workflow Cost Query
# =========================================================================
def getWorkflowCost(self, workflowId: str) -> float:
"""Sum of all transaction amounts for a workflow."""
if not workflowId:
@ -1027,7 +1180,7 @@ class BillingObjects:
except Exception as e:
logger.error(f"Error getting transactions for user: {e}")
allTransactions.sort(key=lambda x: x.get("sysCreatedAt", ""), reverse=True)
_sortBillingTransactionsBySysCreatedAtDesc(allTransactions, "getTransactionsForUser")
return allTransactions[:limit]
# =========================================================================
@ -1133,7 +1286,7 @@ class BillingObjects:
logger.error(f"Error getting mandate transactions: {e}")
# Sort by creation date descending and limit
allTransactions.sort(key=lambda x: x.get("sysCreatedAt", ""), reverse=True)
_sortBillingTransactionsBySysCreatedAtDesc(allTransactions, "getMandateTransactions")
return allTransactions[:limit]
# =========================================================================
@ -1320,5 +1473,5 @@ class BillingObjects:
logger.error(f"Error getting user transactions for mandates: {e}")
# Sort by creation date descending and limit
allTransactions.sort(key=lambda x: x.get("sysCreatedAt", ""), reverse=True)
_sortBillingTransactionsBySysCreatedAtDesc(allTransactions, "getUserTransactionsForMandates")
return allTransactions[:limit]

View file

@ -91,10 +91,20 @@ class KnowledgeObjects:
def deleteFileContentIndex(self, fileId: str) -> bool:
"""Delete a FileContentIndex and all associated ContentChunks."""
existing = self.getFileContentIndex(fileId)
mandateId = (existing or {}).get("mandateId") or ""
chunks = self.db.getRecordset(ContentChunk, recordFilter={"fileId": fileId})
for chunk in chunks:
self.db.recordDelete(ContentChunk, chunk["id"])
return self.db.recordDelete(FileContentIndex, fileId)
ok = self.db.recordDelete(FileContentIndex, fileId)
if ok and mandateId:
try:
from modules.interfaces.interfaceDbBilling import _getRootInterface
_getRootInterface().reconcileMandateStorageBilling(str(mandateId))
except Exception as ex:
logger.warning("reconcileMandateStorageBilling after delete failed: %s", ex)
return ok
# =========================================================================
# ContentChunk CRUD

View file

@ -297,13 +297,17 @@ class SubscriptionObjects:
cap = plan.maxDataVolumeMB
if cap is None:
return True
currentMB = self._getMandateDataVolumeMB(mandateId)
currentMB = self.getMandateDataVolumeMB(mandateId)
if currentMB + delta > cap:
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException
raise SubscriptionCapacityException(resourceType=resourceType, currentCount=int(currentMB), maxAllowed=cap)
return True
def getMandateDataVolumeMB(self, mandateId: str) -> float:
"""Total indexed data volume for the mandate (MB), for billing and capacity checks."""
return self._getMandateDataVolumeMB(mandateId)
def _getMandateDataVolumeMB(self, mandateId: str) -> float:
"""Sum RAG index size (FileContentIndex.totalSize) across all feature instances of the mandate."""
try:
@ -323,7 +327,7 @@ class SubscriptionObjects:
plan = self.getPlan(sub.get("planKey", ""))
if not plan or not plan.maxDataVolumeMB:
return None
usedMB = self._getMandateDataVolumeMB(mandateId)
usedMB = self.getMandateDataVolumeMB(mandateId)
limitMB = plan.maxDataVolumeMB
percent = (usedMB / limitMB * 100) if limitMB > 0 else 0
if percent >= 80:

View file

@ -13,7 +13,7 @@ from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Resp
from typing import List, Dict, Any, Optional
from fastapi import status
import logging
from datetime import date, datetime
from datetime import date, datetime, timezone
from pydantic import BaseModel, Field
# Import auth module
@ -263,6 +263,9 @@ class BillingSettingsUpdate(BaseModel):
warningThresholdPercent: Optional[float] = Field(None, ge=0, le=100)
notifyOnWarning: Optional[bool] = None
notifyEmails: Optional[List[str]] = None
autoRechargeEnabled: Optional[bool] = None
rechargeAmountCHF: Optional[float] = Field(None, gt=0)
rechargeMaxPerMonth: Optional[int] = Field(None, ge=0)
class TransactionResponse(BaseModel):
@ -704,11 +707,13 @@ def createOrUpdateSettings(
targetMandateId: str = Path(..., description="Mandate ID"),
settingsUpdate: BillingSettingsUpdate = Body(...),
ctx: RequestContext = Depends(getRequestContext),
_admin = Depends(requireSysAdminRole)
):
"""
Create or update billing settings for a mandate (SysAdmin only).
Create or update billing settings for a mandate.
Access: SysAdmin (any mandate) or MandateAdmin (own mandate).
"""
if not _isAdminOfMandate(ctx, targetMandateId):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required for this mandate")
try:
billingInterface = getBillingInterface(ctx.user, targetMandateId)
existingSettings = billingInterface.getSettings(targetMandateId)
@ -735,6 +740,21 @@ def createOrUpdateSettings(
else True
),
notifyEmails=settingsUpdate.notifyEmails or [],
autoRechargeEnabled=(
settingsUpdate.autoRechargeEnabled
if settingsUpdate.autoRechargeEnabled is not None
else False
),
rechargeAmountCHF=(
settingsUpdate.rechargeAmountCHF
if settingsUpdate.rechargeAmountCHF is not None
else 10.0
),
rechargeMaxPerMonth=(
settingsUpdate.rechargeMaxPerMonth
if settingsUpdate.rechargeMaxPerMonth is not None
else 3
),
)
return billingInterface.createSettings(newSettings)
@ -1103,7 +1123,8 @@ def _handleSubscriptionWebhook(event) -> None:
from datetime import datetime, timezone
obj = event.data.object
stripeSubId = obj.get("id") if event.type.startswith("customer.subscription") else obj.get("subscription")
rawSub = obj.get("id") if event.type.startswith("customer.subscription") else obj.get("subscription")
stripeSubId = rawSub.get("id") if isinstance(rawSub, dict) else rawSub
if not stripeSubId:
logger.warning("Subscription webhook %s has no subscription ID", event.type)
return
@ -1209,6 +1230,15 @@ def _handleSubscriptionWebhook(event) -> None:
logger.error("Failed to notify about trial ending: %s", e)
elif event.type == "invoice.paid":
period_ts = obj.get("period_start")
if period_ts:
period_start_at = datetime.fromtimestamp(int(period_ts), tz=timezone.utc)
try:
billing_if = _getRootInterface()
billing_if.resetStorageBillingPeriod(mandateId, period_start_at)
billing_if.reconcileMandateStorageBilling(mandateId)
except Exception as ex:
logger.error("Storage billing on invoice.paid failed: %s", ex)
logger.info("Invoice paid for sub %s (mandate %s)", subId, mandateId)
return None

View file

@ -4,7 +4,7 @@
Routes for local security and authentication.
"""
from fastapi import APIRouter, HTTPException, status, Depends, Request, Response, Body, Query, Path
from fastapi import APIRouter, HTTPException, status, Depends, Request, Response, Body, Path
from fastapi.security import OAuth2PasswordRequestForm
import logging
from typing import Dict, Any
@ -822,125 +822,6 @@ def password_reset(
)
# ============================================================
# Voice Preferences (user-level, shared across features)
# ============================================================
@router.get("/voice-preferences")
@limiter.limit("60/minute")
def getVoicePreferences(
request: Request,
currentUser: User = Depends(getCurrentUser),
) -> Dict[str, Any]:
"""Get user's voice/language preferences (optionally scoped to mandate via header)."""
rootInterface = getRootInterface()
from modules.datamodels.datamodelUam import UserVoicePreferences
mandateId = request.headers.get("X-Mandate-Id") or None
userId = str(currentUser.id)
prefs = rootInterface.db.getRecordset(
UserVoicePreferences,
recordFilter={"userId": userId, "mandateId": mandateId}
)
if prefs:
return prefs[0] if isinstance(prefs[0], dict) else prefs[0].model_dump()
return UserVoicePreferences(userId=userId, mandateId=mandateId).model_dump()
@router.put("/voice-preferences")
@limiter.limit("30/minute")
def updateVoicePreferences(
request: Request,
preferences: Dict[str, Any] = Body(...),
currentUser: User = Depends(getCurrentUser),
) -> Dict[str, Any]:
"""Update user's voice/language preferences (upsert)."""
rootInterface = getRootInterface()
from modules.datamodels.datamodelUam import UserVoicePreferences
mandateId = request.headers.get("X-Mandate-Id") or None
userId = str(currentUser.id)
existing = rootInterface.db.getRecordset(
UserVoicePreferences,
recordFilter={"userId": userId, "mandateId": mandateId}
)
allowedFields = {
"sttLanguage", "ttsLanguage", "ttsVoice", "ttsVoiceMap",
"translationSourceLanguage", "translationTargetLanguage",
}
updateData = {k: v for k, v in preferences.items() if k in allowedFields}
if existing:
existingRecord = existing[0]
existingId = existingRecord.get("id") if isinstance(existingRecord, dict) else existingRecord.id
rootInterface.db.recordModify(UserVoicePreferences, existingId, updateData)
updated = rootInterface.db.getRecordset(UserVoicePreferences, recordFilter={"id": existingId})
return updated[0] if updated else {"message": "Updated", **updateData}
else:
newPrefs = UserVoicePreferences(userId=userId, mandateId=mandateId, **updateData)
created = rootInterface.db.recordCreate(UserVoicePreferences, newPrefs.model_dump())
return created if isinstance(created, dict) else created.model_dump()
@router.get("/voice/languages")
@limiter.limit("120/minute")
async def getVoiceLanguages(
request: Request,
currentUser: User = Depends(getCurrentUser),
) -> Dict[str, Any]:
"""Return available TTS languages (user-level, no instance context needed)."""
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
voiceInterface = getVoiceInterface(currentUser)
languagesResult = await voiceInterface.getAvailableLanguages()
languageList = languagesResult.get("languages", []) if isinstance(languagesResult, dict) else languagesResult
return {"languages": languageList}
@router.get("/voice/voices")
@limiter.limit("120/minute")
async def getVoiceVoices(
request: Request,
language: str = Query("de-DE"),
currentUser: User = Depends(getCurrentUser),
) -> Dict[str, Any]:
"""Return available TTS voices for a given language."""
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
voiceInterface = getVoiceInterface(currentUser)
voicesResult = await voiceInterface.getAvailableVoices(language)
voiceList = voicesResult.get("voices", []) if isinstance(voicesResult, dict) else voicesResult
return {"voices": voiceList}
@router.post("/voice/test")
@limiter.limit("30/minute")
async def testVoice(
request: Request,
body: Dict[str, Any] = Body(...),
currentUser: User = Depends(getCurrentUser),
) -> Dict[str, Any]:
"""Test a specific voice with a sample text."""
import base64
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
text = body.get("text", "Hallo, das ist ein Stimmtest.")
language = body.get("language", "de-DE")
voiceId = body.get("voiceId")
voiceInterface = getVoiceInterface(currentUser)
result = await voiceInterface.textToSpeech(text=text, languageCode=language, voiceName=voiceId)
if result and isinstance(result, dict):
audioContent = result.get("audioContent")
if audioContent:
audioB64 = base64.b64encode(
audioContent if isinstance(audioContent, bytes) else audioContent.encode()
).decode()
return {"success": True, "audio": audioB64, "format": "mp3", "text": text}
return {"success": False, "error": "TTS returned no audio"}
# ============================================================
# Neutralization Mappings (user-level, view/delete)
# ============================================================

View file

@ -190,12 +190,22 @@ def getSubscriptionInfo(
mandateId = adminMandateIds[0]
if not mandateId:
return {"plan": None, "maxDataVolumeMB": None, "maxFeatureInstances": None}
return {
"plan": None,
"maxDataVolumeMB": None,
"maxFeatureInstances": None,
"budgetAiCHF": None,
}
from modules.datamodels.datamodelSubscription import MandateSubscription, BUILTIN_PLANS
subs = db.getRecordset(MandateSubscription, recordFilter={"mandateId": mandateId})
if not subs:
return {"plan": None, "maxDataVolumeMB": None, "maxFeatureInstances": None}
return {
"plan": None,
"maxDataVolumeMB": None,
"maxFeatureInstances": None,
"budgetAiCHF": None,
}
sub = subs[0]
plan = BUILTIN_PLANS.get(sub.get("planKey"))
@ -206,12 +216,18 @@ def getSubscriptionInfo(
"status": sub.get("status"),
"maxDataVolumeMB": plan.maxDataVolumeMB if plan else None,
"maxFeatureInstances": plan.maxFeatureInstances if plan else None,
"budgetAiCHF": plan.budgetAiCHF if plan else None,
"currentFeatureInstances": len(currentInstances),
"trialEndsAt": sub.get("trialEndsAt"),
}
except Exception as e:
logger.error(f"Error getting subscription info: {e}")
return {"plan": None, "maxDataVolumeMB": None, "maxFeatureInstances": None}
return {
"plan": None,
"maxDataVolumeMB": None,
"maxFeatureInstances": None,
"budgetAiCHF": None,
}
@router.get("/features", response_model=List[StoreFeatureResponse])

View file

@ -0,0 +1,327 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
User-scoped voice settings and TTS/STT catalog endpoints.
Uses modules.interfaces.interfaceVoiceObjects (voice core) and persists preferences
via UserVoicePreferences same domain as routeVoiceGoogle (Google connector ops).
"""
import base64
import logging
from typing import Any, Dict
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request, status
from modules.auth import getCurrentUser, limiter
from modules.datamodels.datamodelUam import User, UserVoicePreferences
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/api/voice",
tags=["Voice User"],
responses={
404: {"description": "Not found"},
400: {"description": "Bad request"},
401: {"description": "Unauthorized"},
403: {"description": "Forbidden"},
500: {"description": "Internal server error"},
},
)
@router.get("/preferences")
@limiter.limit("60/minute")
def getVoicePreferences(
request: Request,
currentUser: User = Depends(getCurrentUser),
) -> Dict[str, Any]:
"""Get user's voice/language preferences (optionally scoped to mandate via header)."""
rootInterface = getRootInterface()
mandateId = request.headers.get("X-Mandate-Id") or None
userId = str(currentUser.id)
prefs = rootInterface.db.getRecordset(
UserVoicePreferences,
recordFilter={"userId": userId, "mandateId": mandateId},
)
if prefs:
return prefs[0] if isinstance(prefs[0], dict) else prefs[0].model_dump()
return UserVoicePreferences(userId=userId, mandateId=mandateId).model_dump()
@router.put("/preferences")
@limiter.limit("30/minute")
def updateVoicePreferences(
request: Request,
preferences: Dict[str, Any] = Body(...),
currentUser: User = Depends(getCurrentUser),
) -> Dict[str, Any]:
"""Update user's voice/language preferences (upsert)."""
rootInterface = getRootInterface()
mandateId = request.headers.get("X-Mandate-Id") or None
userId = str(currentUser.id)
existing = rootInterface.db.getRecordset(
UserVoicePreferences,
recordFilter={"userId": userId, "mandateId": mandateId},
)
allowedFields = {
"sttLanguage",
"ttsLanguage",
"ttsVoice",
"ttsVoiceMap",
"translationSourceLanguage",
"translationTargetLanguage",
}
updateData = {k: v for k, v in preferences.items() if k in allowedFields}
if existing:
existingRecord = existing[0]
existingId = existingRecord.get("id") if isinstance(existingRecord, dict) else existingRecord.id
rootInterface.db.recordModify(UserVoicePreferences, existingId, updateData)
updated = rootInterface.db.getRecordset(UserVoicePreferences, recordFilter={"id": existingId})
return updated[0] if updated else {"message": "Updated", **updateData}
newPrefs = UserVoicePreferences(userId=userId, mandateId=mandateId, **updateData)
created = rootInterface.db.recordCreate(UserVoicePreferences, newPrefs.model_dump())
return created if isinstance(created, dict) else created.model_dump()
@router.get("/languages")
@limiter.limit("120/minute")
async def getVoiceLanguages(
request: Request,
currentUser: User = Depends(getCurrentUser),
) -> Dict[str, Any]:
"""Return available TTS languages (user-level, no instance context needed)."""
voiceInterface = getVoiceInterface(currentUser)
languagesResult = await voiceInterface.getAvailableLanguages()
languageList = languagesResult.get("languages", []) if isinstance(languagesResult, dict) else languagesResult
return {"languages": languageList}
@router.get("/voices")
@limiter.limit("120/minute")
async def getVoiceVoices(
request: Request,
language: str = Query("de-DE"),
currentUser: User = Depends(getCurrentUser),
) -> Dict[str, Any]:
"""Return available TTS voices for a given language."""
voiceInterface = getVoiceInterface(currentUser)
voicesResult = await voiceInterface.getAvailableVoices(language)
voiceList = voicesResult.get("voices", []) if isinstance(voicesResult, dict) else voicesResult
return {"voices": voiceList}
# Same minimum as modules.serviceCenter.services.serviceAi.mainServiceAi._checkBillingBeforeAiCall
_MIN_AI_BILLING_ESTIMATE_CHF = 0.01
def _userMandateIds(rootInterface, currentUser: User):
memberships = rootInterface.getUserMandates(str(currentUser.id))
out = []
for um in memberships:
mid = getattr(um, "mandateId", None) or (um.get("mandateId") if isinstance(um, dict) else None)
if mid:
out.append(str(mid))
return list(dict.fromkeys(out))
def _mandatePassesAiPoolBilling(currentUser: User, mandateId: str, userId: str) -> bool:
"""True if mandate pool passes the same billing gate as AI calls (subscription + pool >= estimate)."""
from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface
bi = getBillingInterface(currentUser, mandateId)
res = bi.checkBalance(mandateId, userId, _MIN_AI_BILLING_ESTIMATE_CHF)
return bool(res.allowed)
def _mandatePoolBalanceChf(currentUser: User, mandateId: str) -> float:
from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface
bi = getBillingInterface(currentUser, mandateId)
acc = bi.getMandateAccount(mandateId)
if not acc:
return 0.0
return float(acc.get("balance", 0.0) or 0.0)
def _resolveMandateIdForVoiceTestAi(request: Request, currentUser: User) -> str:
"""
AI sample billing uses mandate pool (PREPAY), not per-user wallet.
Prefer X-Mandate-Id when the user is a member and that mandate's pool can pay;
otherwise pick the member mandate with the highest pool balance that passes the AI billing check.
"""
rootInterface = getRootInterface()
userId = str(currentUser.id)
memberIds = _userMandateIds(rootInterface, currentUser)
if not memberIds:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"Voice test needs at least one mandate membership for AI billing. "
"Join a mandate or open the app from a mandate context."
),
)
headerRaw = (request.headers.get("X-Mandate-Id") or request.headers.get("x-mandate-id") or "").strip()
if headerRaw:
if headerRaw not in memberIds:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="X-Mandate-Id is not a mandate you belong to.",
)
if _mandatePassesAiPoolBilling(currentUser, headerRaw, userId):
logger.info(
"Voice test AI billing: using header mandate %s (pool ok for estimate %.4f CHF)",
headerRaw,
_MIN_AI_BILLING_ESTIMATE_CHF,
)
return headerRaw
logger.warning(
"Voice test AI billing: header mandate %s has insufficient mandate pool or subscription; "
"trying other memberships",
headerRaw,
)
bestMid = None
bestBal = -1.0
for mid in memberIds:
if not _mandatePassesAiPoolBilling(currentUser, mid, userId):
continue
bal = _mandatePoolBalanceChf(currentUser, mid)
if bal > bestBal:
bestBal = bal
bestMid = mid
if bestMid:
logger.info(
"Voice test AI billing: selected mandate %s (mandate pool %.2f CHF, estimate %.4f CHF)",
bestMid,
bestBal,
_MIN_AI_BILLING_ESTIMATE_CHF,
)
return bestMid
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail=(
"No mandate you belong to has sufficient shared pool balance for AI (or subscription inactive). "
"Top up the mandate pool or use a mandate with budget."
),
)
def _sanitizeAiTtsSample(raw: str) -> str:
s = (raw or "").strip()
if s.startswith("```"):
nl = s.find("\n")
if nl != -1:
s = s[nl + 1 :]
if s.rstrip().endswith("```"):
s = s.rstrip()[:-3].strip()
if len(s) >= 2 and ((s[0] == s[-1] == '"') or (s[0] == s[-1] == "'")):
s = s[1:-1].strip()
return s
async def _generateTtsSampleTextForLocale(
request: Request,
currentUser: User,
localeTag: str,
) -> str:
from modules.serviceCenter import getService
from modules.serviceCenter.context import ServiceCenterContext
from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, PriorityEnum, ProcessingModeEnum
from modules.serviceCenter.services.serviceBilling.mainServiceBilling import (
BillingContextError,
InsufficientBalanceException,
ProviderNotAllowedException,
)
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionInactiveException
mandateId = _resolveMandateIdForVoiceTestAi(request, currentUser)
ctx = ServiceCenterContext(user=currentUser, mandate_id=mandateId, feature_instance_id=None)
aiService = getService("ai", ctx)
systemPrompt = (
"You write short text-to-speech demo lines for end users.\n"
"Task: Output exactly one or two natural sentences a user would enjoy hearing when testing a voice.\n"
"The entire output MUST be written ONLY in the natural spoken language that matches the given "
"BCP-47 locale tag. Do not use any other language.\n"
"Do not mention locales, tags, tests, artificial intelligence, or these instructions.\n"
"No quotation marks around the text. No markdown. Plain text only."
)
userPrompt = f"BCP-47 locale tag: `{localeTag}`.\nWrite the sample now."
aiRequest = AiCallRequest(
prompt=userPrompt,
context=systemPrompt,
requireNeutralization=False,
options=AiCallOptions(
operationType=OperationTypeEnum.DATA_GENERATE,
priority=PriorityEnum.SPEED,
processingMode=ProcessingModeEnum.BASIC,
compressPrompt=False,
compressContext=False,
temperature=0.75,
maxParts=1,
),
)
try:
response = await aiService.callAi(aiRequest)
except SubscriptionInactiveException as e:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=e.message) from e
except InsufficientBalanceException as e:
raise HTTPException(status_code=status.HTTP_402_PAYMENT_REQUIRED, detail=str(e)) from e
except ProviderNotAllowedException as e:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=getattr(e, "message", None) or str(e),
) from e
except BillingContextError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
content = _sanitizeAiTtsSample(getattr(response, "content", None) or "")
if getattr(response, "errorCount", 0) or not content:
logger.warning("Voice test AI sample empty or errorCount=%s", getattr(response, "errorCount", None))
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Could not generate voice test sample text.",
)
if len(content) > 500:
content = content[:500].rstrip()
return content
@router.post("/test")
@limiter.limit("30/minute")
async def testVoice(
request: Request,
body: Dict[str, Any] = Body(...),
currentUser: User = Depends(getCurrentUser),
) -> Dict[str, Any]:
"""Test a specific voice. Sample text is AI-generated in the voice locale unless `text` is supplied."""
textRaw = body.get("text")
language = body.get("language", "de-DE")
voiceId = body.get("voiceId")
text = (textRaw or "").strip() if isinstance(textRaw, str) else ""
if not text:
text = await _generateTtsSampleTextForLocale(request, currentUser, language)
voiceInterface = getVoiceInterface(currentUser)
result = await voiceInterface.textToSpeech(text=text, languageCode=language, voiceName=voiceId)
if result and isinstance(result, dict):
audioContent = result.get("audioContent")
if audioContent:
audioB64 = base64.b64encode(
audioContent if isinstance(audioContent, bytes) else audioContent.encode()
).decode()
return {"success": True, "audio": audioB64, "format": "mp3", "text": text}
return {"success": False, "error": "TTS returned no audio"}

View file

@ -21,6 +21,8 @@ class ServiceCenterContext:
workflow_id: Optional[str] = None
workflow: Any = None
requireNeutralization: Optional[bool] = None
# When workflow is absent (e.g. workspace agent), billing/UI still need feature code for transactions.
feature_code: Optional[str] = None
@property
def mandateId(self) -> Optional[str]:

View file

@ -322,7 +322,7 @@ class AgentService:
def _createAiCallFn(self) -> Callable[[AiCallRequest], AiCallResponse]:
"""Create the AI call function that wraps serviceAi with billing."""
ctxNeutralization = getattr(self.ctx, 'requireNeutralization', None)
ctxNeutralization = getattr(self._context, "requireNeutralization", None)
async def _aiCallFn(request: AiCallRequest) -> AiCallResponse:
if ctxNeutralization is not None and request.requireNeutralization is None:
request.requireNeutralization = ctxNeutralization
@ -332,7 +332,7 @@ class AgentService:
def _createAiCallStreamFn(self):
"""Create the streaming AI call function. Yields str deltas, then AiCallResponse."""
ctxNeutralization = getattr(self.ctx, 'requireNeutralization', None)
ctxNeutralization = getattr(self._context, "requireNeutralization", None)
async def _aiCallStreamFn(request: AiCallRequest):
if ctxNeutralization is not None and request.requireNeutralization is None:
request.requireNeutralization = ctxNeutralization

View file

@ -77,6 +77,9 @@ class _ServicesAdapter:
@property
def featureCode(self) -> Optional[str]:
fc = getattr(self._context, "feature_code", None)
if fc and str(fc).strip():
return str(fc).strip()
w = self.workflow
if w and hasattr(w, "feature") and w.feature:
return getattr(w.feature, "code", None)
@ -742,9 +745,8 @@ detectedIntent-Werte:
balance_str = f"{(balanceCheck.currentBalance or 0):.2f}"
logger.warning(
f"Billing check failed for user {user.id}: "
f"Balance {balance_str} CHF, "
f"Reason: {reason}"
f"AI billing check failed (mandate pool): mandate={mandateId} user={user.id} "
f"poolBalance={balance_str} CHF required~={estimatedCost:.4f} CHF reason={reason}"
)
ulabel = (getattr(user, "email", None) or getattr(user, "username", None) or str(user.id))
maybeEmailMandatePoolExhausted(

View file

@ -58,6 +58,9 @@ def getService(currentUser: User, mandateId: str, featureInstanceId: str = None,
def _get_feature_code_from_context(context) -> Optional[str]:
"""Extract featureCode from ServiceCenterContext."""
explicit = getattr(context, "feature_code", None)
if explicit and str(explicit).strip():
return str(explicit).strip()
if context.workflow and hasattr(context.workflow, "feature") and context.workflow.feature:
return getattr(context.workflow.feature, "code", None)
return getattr(context.workflow, "featureCode", None) if context.workflow else None

View file

@ -210,6 +210,13 @@ class KnowledgeService:
except Exception as e:
logger.debug(f"Could not set neutralizationStatus for file {fileId}: {e}")
logger.info(f"Indexed file {fileId} ({fileName}): {len(contentObjects)} objects, {len(textObjects)} text chunks")
if mandateId:
try:
from modules.interfaces.interfaceDbBilling import _getRootInterface
_getRootInterface().reconcileMandateStorageBilling(str(mandateId))
except Exception as ex:
logger.warning("reconcileMandateStorageBilling after index failed: %s", ex)
return index
# =========================================================================

View file

@ -99,25 +99,44 @@ try:
except Exception as e:
logger.warning(f"Could not refresh APP_CONFIG: {e}")
# Alle PowerOn Datenbanken
# Alle PowerOn Datenbanken (keep in sync with interfaceBootstrap._POWERON_DATABASE_NAMES)
ALL_DATABASES = [
"poweron_app", # Haupt-App: User, Mandate, RBAC, Features
"poweron_chat", # Chat-Konversationen
"poweron_chatbot", # Chatbot-Feature: Konversationen, Nachrichten, Logs
"poweron_management", # Workflows, Prompts, Connections
"poweron_realestate", # Real Estate
"poweron_trustee", # Trustee
"poweron_app",
"poweron_automation",
"poweron_automation2",
"poweron_billing",
"poweron_chat",
"poweron_chatbot",
"poweron_commcoach",
"poweron_knowledge",
"poweron_management",
"poweron_neutralization",
"poweron_realestate",
"poweron_teamsbot",
"poweron_test",
"poweron_trustee",
"poweron_workspace",
]
# Datenbank-Konfiguration: Mapping von DB-Name zu Config-Prefix
# Jede Datenbank hat ihre eigenen Variablen: DB_APP_HOST, DB_CHAT_HOST, etc.
# Unlisted names use prefix "DB" (DB_HOST, DB_USER, …) via _getDbConfig fallback.
DATABASE_CONFIG = {
"poweron_app": "DB_APP", # DB_APP_HOST, DB_APP_USER, DB_APP_PASSWORD_SECRET, etc.
"poweron_chat": "DB_CHAT", # DB_CHAT_HOST, DB_CHAT_USER, etc.
"poweron_chatbot": "DB_CHATBOT", # DB_CHATBOT_* (fallsback to DB_*)
"poweron_app": "DB_APP",
"poweron_chat": "DB_CHAT",
"poweron_chatbot": "DB_CHATBOT",
"poweron_management": "DB_MANAGEMENT",
"poweron_realestate": "DB_REALESTATE",
"poweron_trustee": "DB_TRUSTEE",
"poweron_automation": "DB",
"poweron_automation2": "DB",
"poweron_billing": "DB",
"poweron_commcoach": "DB",
"poweron_knowledge": "DB",
"poweron_neutralization": "DB",
"poweron_teamsbot": "DB",
"poweron_test": "DB",
"poweron_workspace": "DB",
}

View file

@ -284,10 +284,13 @@ except Exception as e:
print(f" [FAIL] Fix 5: {e}")
try:
with open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"modules", "routes", "routeSecurityLocal.py"), "r") as f:
voiceUserPath = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"modules", "routes", "routeVoiceUser.py",
)
with open(voiceUserPath, "r") as f:
source = f.read()
_check("Voice preferences GET endpoint", "voice-preferences" in source and "getVoicePreferences" in source)
_check("Voice preferences GET endpoint", '"/preferences"' in source and "getVoicePreferences" in source)
_check("Voice preferences PUT endpoint", "updateVoicePreferences" in source)
except Exception as e:
errors.append(f"Fix 5 Routes: {e}")