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}")