From 3ac25a269a0e1a30cdcaf5b55bf2f48589b960f0 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Sun, 29 Mar 2026 12:18:58 +0200
Subject: [PATCH] streamlined billing incl ai and storage budget
---
app.py | 3 +
modules/connectors/connectorDbPostgre.py | 37 +-
modules/connectors/connectorVoiceGoogle.py | 41 ++-
modules/datamodels/datamodelBilling.py | 22 ++
modules/datamodels/datamodelSubscription.py | 4 +-
.../workspace/routeFeatureWorkspace.py | 26 ++
modules/interfaces/interfaceBootstrap.py | 13 +-
modules/interfaces/interfaceDbBilling.py | 169 ++++++++-
modules/interfaces/interfaceDbKnowledge.py | 12 +-
modules/interfaces/interfaceDbSubscription.py | 8 +-
modules/routes/routeBilling.py | 38 +-
modules/routes/routeSecurityLocal.py | 121 +------
modules/routes/routeStore.py | 22 +-
modules/routes/routeVoiceUser.py | 327 ++++++++++++++++++
modules/serviceCenter/context.py | 2 +
.../services/serviceAgent/mainServiceAgent.py | 4 +-
.../services/serviceAi/mainServiceAi.py | 8 +-
.../serviceBilling/mainServiceBilling.py | 3 +
.../serviceKnowledge/mainServiceKnowledge.py | 7 +
scripts/script_db_export_migration.py | 39 ++-
tests/test_phase123_basic.py | 9 +-
21 files changed, 740 insertions(+), 175 deletions(-)
create mode 100644 modules/routes/routeVoiceUser.py
diff --git a/app.py b/app.py
index 80a9505c..0f3d29a6 100644
--- a/app.py
+++ b/app.py
@@ -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)
diff --git a/modules/connectors/connectorDbPostgre.py b/modules/connectors/connectorDbPostgre.py
index bf8fce44..6bd661b4 100644
--- a/modules/connectors/connectorDbPostgre.py
+++ b/modules/connectors/connectorDbPostgre.py
@@ -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}")
diff --git a/modules/connectors/connectorVoiceGoogle.py b/modules/connectors/connectorVoiceGoogle.py
index ddb0d864..0dbb46a5 100644
--- a/modules/connectors/connectorVoiceGoogle.py
+++ b/modules/connectors/connectorVoiceGoogle.py
@@ -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
diff --git a/modules/datamodels/datamodelBilling.py b/modules/datamodels/datamodelBilling.py
index a0bb4f88..2d3bfdb1 100644
--- a/modules/datamodels/datamodelBilling.py
+++ b/modules/datamodels/datamodelBilling.py
@@ -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)",
+ },
},
)
diff --git a/modules/datamodels/datamodelSubscription.py b/modules/datamodels/datamodelSubscription.py
index 3b0e46b9..c5547c0a 100644
--- a/modules/datamodels/datamodelSubscription.py
+++ b/modules/datamodels/datamodelSubscription.py
@@ -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,
),
}
diff --git a/modules/features/workspace/routeFeatureWorkspace.py b/modules/features/workspace/routeFeatureWorkspace.py
index 79295f35..6271a8cd 100644
--- a/modules/features/workspace/routeFeatureWorkspace.py
+++ b/modules/features/workspace/routeFeatureWorkspace.py
@@ -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)
diff --git a/modules/interfaces/interfaceBootstrap.py b/modules/interfaces/interfaceBootstrap.py
index 0c186475..7eccb3ee 100644
--- a/modules/interfaces/interfaceBootstrap.py
+++ b/modules/interfaces/interfaceBootstrap.py
@@ -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")
diff --git a/modules/interfaces/interfaceDbBilling.py b/modules/interfaces/interfaceDbBilling.py
index c8c13d13..1069314f 100644
--- a/modules/interfaces/interfaceDbBilling.py
+++ b/modules/interfaces/interfaceDbBilling.py
@@ -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]
diff --git a/modules/interfaces/interfaceDbKnowledge.py b/modules/interfaces/interfaceDbKnowledge.py
index c7f50543..c7b9e29a 100644
--- a/modules/interfaces/interfaceDbKnowledge.py
+++ b/modules/interfaces/interfaceDbKnowledge.py
@@ -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
diff --git a/modules/interfaces/interfaceDbSubscription.py b/modules/interfaces/interfaceDbSubscription.py
index 2405ec73..d6832f14 100644
--- a/modules/interfaces/interfaceDbSubscription.py
+++ b/modules/interfaces/interfaceDbSubscription.py
@@ -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:
diff --git a/modules/routes/routeBilling.py b/modules/routes/routeBilling.py
index 37674e53..13e94559 100644
--- a/modules/routes/routeBilling.py
+++ b/modules/routes/routeBilling.py
@@ -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
diff --git a/modules/routes/routeSecurityLocal.py b/modules/routes/routeSecurityLocal.py
index fb71444b..11b6cb0f 100644
--- a/modules/routes/routeSecurityLocal.py
+++ b/modules/routes/routeSecurityLocal.py
@@ -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)
# ============================================================
diff --git a/modules/routes/routeStore.py b/modules/routes/routeStore.py
index 19b81ca7..c9512d3f 100644
--- a/modules/routes/routeStore.py
+++ b/modules/routes/routeStore.py
@@ -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])
diff --git a/modules/routes/routeVoiceUser.py b/modules/routes/routeVoiceUser.py
new file mode 100644
index 00000000..9b628eeb
--- /dev/null
+++ b/modules/routes/routeVoiceUser.py
@@ -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"}
diff --git a/modules/serviceCenter/context.py b/modules/serviceCenter/context.py
index acad6d61..24868fca 100644
--- a/modules/serviceCenter/context.py
+++ b/modules/serviceCenter/context.py
@@ -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]:
diff --git a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py
index c4e1f877..23a749ab 100644
--- a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py
+++ b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py
@@ -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
diff --git a/modules/serviceCenter/services/serviceAi/mainServiceAi.py b/modules/serviceCenter/services/serviceAi/mainServiceAi.py
index b25374d5..e2de43e6 100644
--- a/modules/serviceCenter/services/serviceAi/mainServiceAi.py
+++ b/modules/serviceCenter/services/serviceAi/mainServiceAi.py
@@ -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(
diff --git a/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py b/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py
index 3a33f1f6..90c9a347 100644
--- a/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py
+++ b/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py
@@ -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
diff --git a/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py b/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py
index 7d85edcc..0f20bc7f 100644
--- a/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py
+++ b/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py
@@ -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
# =========================================================================
diff --git a/scripts/script_db_export_migration.py b/scripts/script_db_export_migration.py
index e5961e23..b85dcf54 100644
--- a/scripts/script_db_export_migration.py
+++ b/scripts/script_db_export_migration.py
@@ -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",
}
diff --git a/tests/test_phase123_basic.py b/tests/test_phase123_basic.py
index 222c6043..d13c4271 100644
--- a/tests/test_phase123_basic.py
+++ b/tests/test_phase123_basic.py
@@ -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}")