streamlined billing incl ai and storage budget
This commit is contained in:
parent
c12a75f87f
commit
3ac25a269a
21 changed files with 740 additions and 175 deletions
3
app.py
3
app.py
|
|
@ -566,6 +566,9 @@ app.include_router(googleRouter)
|
||||||
from modules.routes.routeVoiceGoogle import router as voiceGoogleRouter
|
from modules.routes.routeVoiceGoogle import router as voiceGoogleRouter
|
||||||
app.include_router(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
|
from modules.routes.routeSecurityAdmin import router as adminSecurityRouter
|
||||||
app.include_router(adminSecurityRouter)
|
app.include_router(adminSecurityRouter)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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})")
|
logger.warning(f"Could not parse JSONB field {fieldName}, keeping as string ({context})")
|
||||||
|
|
||||||
|
|
||||||
# Legacy system columns (underscore-prefixed internal names) -> PowerOn sys* columns.
|
# Legacy column names (historical _* internal names and old camelCase audit fields) -> PowerOn sys* columns.
|
||||||
_LEGACY_UNDERSCORE_TO_SYS: Tuple[Tuple[str, str], ...] = (
|
# 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"),
|
("_createdAt", "sysCreatedAt"),
|
||||||
("_createdBy", "sysCreatedBy"),
|
("_createdBy", "sysCreatedBy"),
|
||||||
("_modifiedAt", "sysModifiedAt"),
|
("_modifiedAt", "sysModifiedAt"),
|
||||||
("_modifiedBy", "sysModifiedBy"),
|
("_modifiedBy", "sysModifiedBy"),
|
||||||
|
("createdAt", "sysCreatedAt"),
|
||||||
|
("creationDate", "sysCreatedAt"),
|
||||||
|
("updatedAt", "sysModifiedAt"),
|
||||||
|
("lastModified", "sysModifiedAt"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -454,9 +459,9 @@ class DatabaseConnector:
|
||||||
def migrateLegacyUnderscoreSysColumns(self) -> int:
|
def migrateLegacyUnderscoreSysColumns(self) -> int:
|
||||||
"""
|
"""
|
||||||
Scan all public base tables on this connection's database. Where both a legacy
|
Scan all public base tables on this connection's database. Where both a legacy
|
||||||
_createdAt / _createdBy / _modifiedAt / _modifiedBy column (any case) and the
|
source column (any case: _createdAt, createdAt, creationDate, …) and the matching
|
||||||
matching sys* column exist, copy into sys* rows where sys* IS NULL and legacy IS NOT NULL.
|
sys* column exist, UPDATE sys* from legacy where sys* IS NULL AND legacy IS NOT NULL.
|
||||||
Idempotent; safe to run on every bootstrap.
|
Idempotent; run after schema adds sys* columns (see _ensureTableExists).
|
||||||
"""
|
"""
|
||||||
self._ensure_connection()
|
self._ensure_connection()
|
||||||
total = 0
|
total = 0
|
||||||
|
|
@ -466,7 +471,7 @@ class DatabaseConnector:
|
||||||
for table in tableNames:
|
for table in tableNames:
|
||||||
with self.connection.cursor() as cursor:
|
with self.connection.cursor() as cursor:
|
||||||
cols = _listTableColumnNames(cursor, table)
|
cols = _listTableColumnNames(cursor, table)
|
||||||
for legacyLogical, sysLogical in _LEGACY_UNDERSCORE_TO_SYS:
|
for legacyLogical, sysLogical in _LEGACY_FIELD_TO_SYS:
|
||||||
src = _resolveColumnCaseInsensitive(cols, legacyLogical)
|
src = _resolveColumnCaseInsensitive(cols, legacyLogical)
|
||||||
tgt = _resolveColumnCaseInsensitive(cols, sysLogical)
|
tgt = _resolveColumnCaseInsensitive(cols, sysLogical)
|
||||||
if not src or not tgt or src == tgt:
|
if not src or not tgt or src == tgt:
|
||||||
|
|
@ -629,6 +634,7 @@ class DatabaseConnector:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._ensure_connection()
|
self._ensure_connection()
|
||||||
|
schemaTouched = False
|
||||||
|
|
||||||
with self.connection.cursor() as cursor:
|
with self.connection.cursor() as cursor:
|
||||||
# Check if table exists by querying information_schema with case-insensitive search
|
# Check if table exists by querying information_schema with case-insensitive search
|
||||||
|
|
@ -647,6 +653,7 @@ class DatabaseConnector:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Created table '{table}' with columns from Pydantic model"
|
f"Created table '{table}' with columns from Pydantic model"
|
||||||
)
|
)
|
||||||
|
schemaTouched = True
|
||||||
else:
|
else:
|
||||||
# Table exists: ensure all columns from model are present (simple additive migration)
|
# Table exists: ensure all columns from model are present (simple additive migration)
|
||||||
try:
|
try:
|
||||||
|
|
@ -680,6 +687,7 @@ class DatabaseConnector:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Added missing column '{col}' ({sql_type}) to '{table}'"
|
f"Added missing column '{col}' ({sql_type}) to '{table}'"
|
||||||
)
|
)
|
||||||
|
schemaTouched = True
|
||||||
except Exception as add_err:
|
except Exception as add_err:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Could not add column '{col}' to '{table}': {add_err}"
|
f"Could not add column '{col}' to '{table}': {add_err}"
|
||||||
|
|
@ -690,6 +698,23 @@ class DatabaseConnector:
|
||||||
)
|
)
|
||||||
|
|
||||||
self.connection.commit()
|
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
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error ensuring table {table} exists: {e}")
|
logger.error(f"Error ensuring table {table} exists: {e}")
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,11 @@ from modules.shared.configuration import APP_CONFIG
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
class ConnectorGoogleSpeech:
|
||||||
"""
|
"""
|
||||||
Google Cloud Speech-to-Text and Translation connector.
|
Google Cloud Speech-to-Text and Translation connector.
|
||||||
|
|
@ -902,6 +907,13 @@ class ConnectorGoogleSpeech:
|
||||||
"error": f"Validation error: {e}"
|
"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]:
|
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.
|
Convert text to speech using Google Cloud Text-to-Speech.
|
||||||
|
|
@ -917,9 +929,6 @@ class ConnectorGoogleSpeech:
|
||||||
try:
|
try:
|
||||||
logger.info(f"Converting text to speech: '{text[:50]}...' in {languageCode}")
|
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
|
# Build the voice request
|
||||||
selectedVoice = voiceName or self._getDefaultVoice(languageCode)
|
selectedVoice = voiceName or self._getDefaultVoice(languageCode)
|
||||||
|
|
||||||
|
|
@ -931,11 +940,24 @@ class ConnectorGoogleSpeech:
|
||||||
|
|
||||||
logger.info(f"Using TTS voice: {selectedVoice} for language: {languageCode}")
|
logger.info(f"Using TTS voice: {selectedVoice} for language: {languageCode}")
|
||||||
|
|
||||||
voice = texttospeech.VoiceSelectionParams(
|
if self._isGeminiTtsSpeakerVoiceName(selectedVoice):
|
||||||
language_code=languageCode,
|
synthesisInput = texttospeech.SynthesisInput(
|
||||||
name=selectedVoice,
|
text=text,
|
||||||
ssml_gender=texttospeech.SsmlVoiceGender.NEUTRAL
|
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
|
# Select the type of audio file to return
|
||||||
audioConfig = texttospeech.AudioConfig(
|
audioConfig = texttospeech.AudioConfig(
|
||||||
|
|
@ -1059,7 +1081,8 @@ class ConnectorGoogleSpeech:
|
||||||
"language_codes": list(voice.language_codes) if voice.language_codes else [],
|
"language_codes": list(voice.language_codes) if voice.language_codes else [],
|
||||||
"gender": gender,
|
"gender": gender,
|
||||||
"ssml_gender": voice.ssml_gender.name if voice.ssml_gender else "NEUTRAL",
|
"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
|
# Include any additional fields if available from Google API
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,9 @@ from modules.datamodels.datamodelBase import PowerOnModel
|
||||||
from modules.shared.attributeUtils import registerModelLabels
|
from modules.shared.attributeUtils import registerModelLabels
|
||||||
import uuid
|
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):
|
class TransactionTypeEnum(str, Enum):
|
||||||
"""Transaction types for billing."""
|
"""Transaction types for billing."""
|
||||||
|
|
@ -24,6 +27,7 @@ class ReferenceTypeEnum(str, Enum):
|
||||||
PAYMENT = "PAYMENT" # Payment/top-up
|
PAYMENT = "PAYMENT" # Payment/top-up
|
||||||
ADMIN = "ADMIN" # Admin adjustment
|
ADMIN = "ADMIN" # Admin adjustment
|
||||||
SYSTEM = "SYSTEM" # System credit (e.g., initial credit)
|
SYSTEM = "SYSTEM" # System credit (e.g., initial credit)
|
||||||
|
STORAGE = "STORAGE" # Metered storage overage (prepay pool)
|
||||||
|
|
||||||
|
|
||||||
class PeriodTypeEnum(str, Enum):
|
class PeriodTypeEnum(str, Enum):
|
||||||
|
|
@ -137,6 +141,18 @@ class BillingSettings(BaseModel):
|
||||||
)
|
)
|
||||||
notifyOnWarning: bool = Field(default=True, description="Send email when warning threshold is reached")
|
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(
|
registerModelLabels(
|
||||||
"BillingSettings",
|
"BillingSettings",
|
||||||
|
|
@ -154,6 +170,12 @@ registerModelLabels(
|
||||||
"de": "E-Mails fuer Billing-Alerts (Inhaber/Admin)",
|
"de": "E-Mails fuer Billing-Alerts (Inhaber/Admin)",
|
||||||
},
|
},
|
||||||
"notifyOnWarning": {"en": "Notify on Warning", "de": "Bei Warnung benachrichtigen"},
|
"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)",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -218,7 +218,7 @@ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
|
||||||
billingPeriod=BillingPeriodEnum.MONTHLY,
|
billingPeriod=BillingPeriodEnum.MONTHLY,
|
||||||
pricePerUserCHF=90.0,
|
pricePerUserCHF=90.0,
|
||||||
pricePerFeatureInstanceCHF=150.0,
|
pricePerFeatureInstanceCHF=150.0,
|
||||||
maxDataVolumeMB=10240,
|
maxDataVolumeMB=1024,
|
||||||
budgetAiCHF=10.0,
|
budgetAiCHF=10.0,
|
||||||
),
|
),
|
||||||
"STANDARD_YEARLY": SubscriptionPlan(
|
"STANDARD_YEARLY": SubscriptionPlan(
|
||||||
|
|
@ -232,7 +232,7 @@ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
|
||||||
billingPeriod=BillingPeriodEnum.YEARLY,
|
billingPeriod=BillingPeriodEnum.YEARLY,
|
||||||
pricePerUserCHF=1080.0,
|
pricePerUserCHF=1080.0,
|
||||||
pricePerFeatureInstanceCHF=1800.0,
|
pricePerFeatureInstanceCHF=1800.0,
|
||||||
maxDataVolumeMB=10240,
|
maxDataVolumeMB=1024,
|
||||||
budgetAiCHF=120.0,
|
budgetAiCHF=120.0,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,27 @@ class _PendingEditsStore:
|
||||||
_pendingEditsStore = _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):
|
class WorkspaceInputRequest(BaseModel):
|
||||||
"""Prompt input for the unified workspace."""
|
"""Prompt input for the unified workspace."""
|
||||||
prompt: str = Field(description="User prompt text")
|
prompt: str = Field(description="User prompt text")
|
||||||
|
|
@ -546,11 +567,13 @@ async def streamWorkspaceStart(
|
||||||
from modules.serviceCenter import getService
|
from modules.serviceCenter import getService
|
||||||
from modules.serviceCenter.context import ServiceCenterContext
|
from modules.serviceCenter.context import ServiceCenterContext
|
||||||
|
|
||||||
|
wsBillingFeatureCode = _workspaceBillingFeatureCode(context.user, mandateId or "", instanceId)
|
||||||
svcCtx = ServiceCenterContext(
|
svcCtx = ServiceCenterContext(
|
||||||
user=context.user,
|
user=context.user,
|
||||||
mandate_id=mandateId or "",
|
mandate_id=mandateId or "",
|
||||||
feature_instance_id=instanceId,
|
feature_instance_id=instanceId,
|
||||||
workflow_id=workflowId,
|
workflow_id=workflowId,
|
||||||
|
feature_code=wsBillingFeatureCode,
|
||||||
)
|
)
|
||||||
chatSvc = getService("chat", svcCtx)
|
chatSvc = getService("chat", svcCtx)
|
||||||
attachmentLabel = _buildWorkspaceAttachmentLabel(
|
attachmentLabel = _buildWorkspaceAttachmentLabel(
|
||||||
|
|
@ -590,6 +613,7 @@ async def streamWorkspaceStart(
|
||||||
instanceConfig=instanceConfig,
|
instanceConfig=instanceConfig,
|
||||||
allowedProviders=userInput.allowedProviders,
|
allowedProviders=userInput.allowedProviders,
|
||||||
requireNeutralization=userInput.requireNeutralization,
|
requireNeutralization=userInput.requireNeutralization,
|
||||||
|
billingFeatureCode=wsBillingFeatureCode,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
eventManager.register_agent_task(queueId, agentTask)
|
eventManager.register_agent_task(queueId, agentTask)
|
||||||
|
|
@ -646,6 +670,7 @@ async def _runWorkspaceAgent(
|
||||||
instanceConfig: Dict[str, Any] = None,
|
instanceConfig: Dict[str, Any] = None,
|
||||||
allowedProviders: List[str] = None,
|
allowedProviders: List[str] = None,
|
||||||
requireNeutralization: Optional[bool] = None,
|
requireNeutralization: Optional[bool] = None,
|
||||||
|
billingFeatureCode: Optional[str] = None,
|
||||||
):
|
):
|
||||||
"""Run the serviceAgent loop and forward events to the SSE queue."""
|
"""Run the serviceAgent loop and forward events to the SSE queue."""
|
||||||
try:
|
try:
|
||||||
|
|
@ -656,6 +681,7 @@ async def _runWorkspaceAgent(
|
||||||
mandate_id=mandateId,
|
mandate_id=mandateId,
|
||||||
feature_instance_id=instanceId,
|
feature_instance_id=instanceId,
|
||||||
workflow_id=workflowId,
|
workflow_id=workflowId,
|
||||||
|
feature_code=billingFeatureCode,
|
||||||
)
|
)
|
||||||
agentService = getService("agent", ctx)
|
agentService = getService("agent", ctx)
|
||||||
chatService = getService("chat", ctx)
|
chatService = getService("chat", ctx)
|
||||||
|
|
|
||||||
|
|
@ -38,15 +38,23 @@ pwdContext = CryptContext(schemes=["argon2"], deprecated="auto")
|
||||||
# Cache für Role-IDs (roleLabel -> roleId)
|
# Cache für Role-IDs (roleLabel -> roleId)
|
||||||
_roleIdCache: Dict[str, str] = {}
|
_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_DATABASE_NAMES: Tuple[str, ...] = (
|
||||||
"poweron_app",
|
"poweron_app",
|
||||||
|
"poweron_automation",
|
||||||
|
"poweron_automation2",
|
||||||
|
"poweron_billing",
|
||||||
"poweron_chat",
|
"poweron_chat",
|
||||||
"poweron_chatbot",
|
"poweron_chatbot",
|
||||||
|
"poweron_commcoach",
|
||||||
|
"poweron_knowledge",
|
||||||
"poweron_management",
|
"poweron_management",
|
||||||
|
"poweron_neutralization",
|
||||||
"poweron_realestate",
|
"poweron_realestate",
|
||||||
|
"poweron_teamsbot",
|
||||||
|
"poweron_test",
|
||||||
"poweron_trustee",
|
"poweron_trustee",
|
||||||
"poweron_automation",
|
"poweron_workspace",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -60,6 +68,7 @@ def _configPrefixForPoweronDatabase(dbName: str) -> str:
|
||||||
"poweron_trustee": "DB_TRUSTEE",
|
"poweron_trustee": "DB_TRUSTEE",
|
||||||
# Same as initAutomationTemplates: default DB_* (not a separate DB_AUTOMATION_* prefix).
|
# Same as initAutomationTemplates: default DB_* (not a separate DB_AUTOMATION_* prefix).
|
||||||
"poweron_automation": "DB",
|
"poweron_automation": "DB",
|
||||||
|
"poweron_billing": "DB",
|
||||||
}.get(dbName, "DB")
|
}.get(dbName, "DB")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ All billing data is stored in the poweron_billing database.
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, Any, List, Optional, Union
|
from typing import Dict, Any, List, Optional, Union
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta, timezone
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||||
|
|
@ -29,11 +29,44 @@ from modules.datamodels.datamodelBilling import (
|
||||||
PeriodTypeEnum,
|
PeriodTypeEnum,
|
||||||
BillingBalanceResponse,
|
BillingBalanceResponse,
|
||||||
BillingCheckResult,
|
BillingCheckResult,
|
||||||
|
STORAGE_PRICE_PER_GB_CHF,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
def _getAppDatabaseConnector() -> DatabaseConnector:
|
||||||
"""App DB connector (same config as UserMandate reads in this module)."""
|
"""App DB connector (same config as UserMandate reads in this module)."""
|
||||||
return DatabaseConnector(
|
return DatabaseConnector(
|
||||||
|
|
@ -553,6 +586,17 @@ class BillingObjects:
|
||||||
|
|
||||||
# Create transaction record (always on transaction.accountId for audit)
|
# Create transaction record (always on transaction.accountId for audit)
|
||||||
transactionDict = transaction.model_dump(exclude_none=True)
|
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)
|
created = self.db.recordCreate(BillingTransaction, transactionDict)
|
||||||
|
|
||||||
# Update balance on the target account
|
# Update balance on the target account
|
||||||
|
|
@ -597,6 +641,10 @@ class BillingObjects:
|
||||||
pagination=pagination,
|
pagination=pagination,
|
||||||
recordFilter=recordFilter
|
recordFilter=recordFilter
|
||||||
)
|
)
|
||||||
|
_logBillingTransactionsMissingSysCreatedAt(
|
||||||
|
result["items"],
|
||||||
|
"getTransactions(accountId) paginated",
|
||||||
|
)
|
||||||
return PaginatedResult(
|
return PaginatedResult(
|
||||||
items=result["items"],
|
items=result["items"],
|
||||||
totalItems=result["totalItems"],
|
totalItems=result["totalItems"],
|
||||||
|
|
@ -619,7 +667,7 @@ class BillingObjects:
|
||||||
filtered.append(t)
|
filtered.append(t)
|
||||||
results = filtered
|
results = filtered
|
||||||
|
|
||||||
results.sort(key=lambda x: x.get("sysCreatedAt", ""), reverse=True)
|
_sortBillingTransactionsBySysCreatedAtDesc(results, "getTransactions(accountId)")
|
||||||
|
|
||||||
return results[offset:offset + limit]
|
return results[offset:offset + limit]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -674,7 +722,10 @@ class BillingObjects:
|
||||||
transactions = self.getTransactions(account["id"], limit=limit)
|
transactions = self.getTransactions(account["id"], limit=limit)
|
||||||
allTransactions.extend(transactions)
|
allTransactions.extend(transactions)
|
||||||
|
|
||||||
allTransactions.sort(key=lambda x: x.get("sysCreatedAt", ""), reverse=True)
|
_sortBillingTransactionsBySysCreatedAtDesc(
|
||||||
|
allTransactions,
|
||||||
|
"getTransactionsByMandate",
|
||||||
|
)
|
||||||
return allTransactions[:limit]
|
return allTransactions[:limit]
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
@ -816,11 +867,113 @@ class BillingObjects:
|
||||||
|
|
||||||
poolAccount = self.getOrCreateMandateAccount(mandateId)
|
poolAccount = self.getOrCreateMandateAccount(mandateId)
|
||||||
return self.createTransaction(transaction, balanceAccountId=poolAccount["id"])
|
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
|
# Workflow Cost Query
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
def getWorkflowCost(self, workflowId: str) -> float:
|
def getWorkflowCost(self, workflowId: str) -> float:
|
||||||
"""Sum of all transaction amounts for a workflow."""
|
"""Sum of all transaction amounts for a workflow."""
|
||||||
if not workflowId:
|
if not workflowId:
|
||||||
|
|
@ -1027,7 +1180,7 @@ class BillingObjects:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting transactions for user: {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]
|
return allTransactions[:limit]
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
@ -1133,7 +1286,7 @@ class BillingObjects:
|
||||||
logger.error(f"Error getting mandate transactions: {e}")
|
logger.error(f"Error getting mandate transactions: {e}")
|
||||||
|
|
||||||
# Sort by creation date descending and limit
|
# Sort by creation date descending and limit
|
||||||
allTransactions.sort(key=lambda x: x.get("sysCreatedAt", ""), reverse=True)
|
_sortBillingTransactionsBySysCreatedAtDesc(allTransactions, "getMandateTransactions")
|
||||||
return allTransactions[:limit]
|
return allTransactions[:limit]
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
@ -1320,5 +1473,5 @@ class BillingObjects:
|
||||||
logger.error(f"Error getting user transactions for mandates: {e}")
|
logger.error(f"Error getting user transactions for mandates: {e}")
|
||||||
|
|
||||||
# Sort by creation date descending and limit
|
# Sort by creation date descending and limit
|
||||||
allTransactions.sort(key=lambda x: x.get("sysCreatedAt", ""), reverse=True)
|
_sortBillingTransactionsBySysCreatedAtDesc(allTransactions, "getUserTransactionsForMandates")
|
||||||
return allTransactions[:limit]
|
return allTransactions[:limit]
|
||||||
|
|
|
||||||
|
|
@ -91,10 +91,20 @@ class KnowledgeObjects:
|
||||||
|
|
||||||
def deleteFileContentIndex(self, fileId: str) -> bool:
|
def deleteFileContentIndex(self, fileId: str) -> bool:
|
||||||
"""Delete a FileContentIndex and all associated ContentChunks."""
|
"""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})
|
chunks = self.db.getRecordset(ContentChunk, recordFilter={"fileId": fileId})
|
||||||
for chunk in chunks:
|
for chunk in chunks:
|
||||||
self.db.recordDelete(ContentChunk, chunk["id"])
|
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
|
# ContentChunk CRUD
|
||||||
|
|
|
||||||
|
|
@ -297,13 +297,17 @@ class SubscriptionObjects:
|
||||||
cap = plan.maxDataVolumeMB
|
cap = plan.maxDataVolumeMB
|
||||||
if cap is None:
|
if cap is None:
|
||||||
return True
|
return True
|
||||||
currentMB = self._getMandateDataVolumeMB(mandateId)
|
currentMB = self.getMandateDataVolumeMB(mandateId)
|
||||||
if currentMB + delta > cap:
|
if currentMB + delta > cap:
|
||||||
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException
|
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException
|
||||||
raise SubscriptionCapacityException(resourceType=resourceType, currentCount=int(currentMB), maxAllowed=cap)
|
raise SubscriptionCapacityException(resourceType=resourceType, currentCount=int(currentMB), maxAllowed=cap)
|
||||||
|
|
||||||
return True
|
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:
|
def _getMandateDataVolumeMB(self, mandateId: str) -> float:
|
||||||
"""Sum RAG index size (FileContentIndex.totalSize) across all feature instances of the mandate."""
|
"""Sum RAG index size (FileContentIndex.totalSize) across all feature instances of the mandate."""
|
||||||
try:
|
try:
|
||||||
|
|
@ -323,7 +327,7 @@ class SubscriptionObjects:
|
||||||
plan = self.getPlan(sub.get("planKey", ""))
|
plan = self.getPlan(sub.get("planKey", ""))
|
||||||
if not plan or not plan.maxDataVolumeMB:
|
if not plan or not plan.maxDataVolumeMB:
|
||||||
return None
|
return None
|
||||||
usedMB = self._getMandateDataVolumeMB(mandateId)
|
usedMB = self.getMandateDataVolumeMB(mandateId)
|
||||||
limitMB = plan.maxDataVolumeMB
|
limitMB = plan.maxDataVolumeMB
|
||||||
percent = (usedMB / limitMB * 100) if limitMB > 0 else 0
|
percent = (usedMB / limitMB * 100) if limitMB > 0 else 0
|
||||||
if percent >= 80:
|
if percent >= 80:
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Resp
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
from fastapi import status
|
from fastapi import status
|
||||||
import logging
|
import logging
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime, timezone
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
# Import auth module
|
# Import auth module
|
||||||
|
|
@ -263,6 +263,9 @@ class BillingSettingsUpdate(BaseModel):
|
||||||
warningThresholdPercent: Optional[float] = Field(None, ge=0, le=100)
|
warningThresholdPercent: Optional[float] = Field(None, ge=0, le=100)
|
||||||
notifyOnWarning: Optional[bool] = None
|
notifyOnWarning: Optional[bool] = None
|
||||||
notifyEmails: Optional[List[str]] = 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):
|
class TransactionResponse(BaseModel):
|
||||||
|
|
@ -704,11 +707,13 @@ def createOrUpdateSettings(
|
||||||
targetMandateId: str = Path(..., description="Mandate ID"),
|
targetMandateId: str = Path(..., description="Mandate ID"),
|
||||||
settingsUpdate: BillingSettingsUpdate = Body(...),
|
settingsUpdate: BillingSettingsUpdate = Body(...),
|
||||||
ctx: RequestContext = Depends(getRequestContext),
|
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:
|
try:
|
||||||
billingInterface = getBillingInterface(ctx.user, targetMandateId)
|
billingInterface = getBillingInterface(ctx.user, targetMandateId)
|
||||||
existingSettings = billingInterface.getSettings(targetMandateId)
|
existingSettings = billingInterface.getSettings(targetMandateId)
|
||||||
|
|
@ -735,6 +740,21 @@ def createOrUpdateSettings(
|
||||||
else True
|
else True
|
||||||
),
|
),
|
||||||
notifyEmails=settingsUpdate.notifyEmails or [],
|
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)
|
return billingInterface.createSettings(newSettings)
|
||||||
|
|
@ -1103,7 +1123,8 @@ def _handleSubscriptionWebhook(event) -> None:
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
obj = event.data.object
|
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:
|
if not stripeSubId:
|
||||||
logger.warning("Subscription webhook %s has no subscription ID", event.type)
|
logger.warning("Subscription webhook %s has no subscription ID", event.type)
|
||||||
return
|
return
|
||||||
|
|
@ -1209,6 +1230,15 @@ def _handleSubscriptionWebhook(event) -> None:
|
||||||
logger.error("Failed to notify about trial ending: %s", e)
|
logger.error("Failed to notify about trial ending: %s", e)
|
||||||
|
|
||||||
elif event.type == "invoice.paid":
|
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)
|
logger.info("Invoice paid for sub %s (mandate %s)", subId, mandateId)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
Routes for local security and authentication.
|
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
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, Any
|
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)
|
# Neutralization Mappings (user-level, view/delete)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
|
||||||
|
|
@ -190,12 +190,22 @@ def getSubscriptionInfo(
|
||||||
mandateId = adminMandateIds[0]
|
mandateId = adminMandateIds[0]
|
||||||
|
|
||||||
if not mandateId:
|
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
|
from modules.datamodels.datamodelSubscription import MandateSubscription, BUILTIN_PLANS
|
||||||
subs = db.getRecordset(MandateSubscription, recordFilter={"mandateId": mandateId})
|
subs = db.getRecordset(MandateSubscription, recordFilter={"mandateId": mandateId})
|
||||||
if not subs:
|
if not subs:
|
||||||
return {"plan": None, "maxDataVolumeMB": None, "maxFeatureInstances": None}
|
return {
|
||||||
|
"plan": None,
|
||||||
|
"maxDataVolumeMB": None,
|
||||||
|
"maxFeatureInstances": None,
|
||||||
|
"budgetAiCHF": None,
|
||||||
|
}
|
||||||
|
|
||||||
sub = subs[0]
|
sub = subs[0]
|
||||||
plan = BUILTIN_PLANS.get(sub.get("planKey"))
|
plan = BUILTIN_PLANS.get(sub.get("planKey"))
|
||||||
|
|
@ -206,12 +216,18 @@ def getSubscriptionInfo(
|
||||||
"status": sub.get("status"),
|
"status": sub.get("status"),
|
||||||
"maxDataVolumeMB": plan.maxDataVolumeMB if plan else None,
|
"maxDataVolumeMB": plan.maxDataVolumeMB if plan else None,
|
||||||
"maxFeatureInstances": plan.maxFeatureInstances if plan else None,
|
"maxFeatureInstances": plan.maxFeatureInstances if plan else None,
|
||||||
|
"budgetAiCHF": plan.budgetAiCHF if plan else None,
|
||||||
"currentFeatureInstances": len(currentInstances),
|
"currentFeatureInstances": len(currentInstances),
|
||||||
"trialEndsAt": sub.get("trialEndsAt"),
|
"trialEndsAt": sub.get("trialEndsAt"),
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting subscription info: {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])
|
@router.get("/features", response_model=List[StoreFeatureResponse])
|
||||||
|
|
|
||||||
327
modules/routes/routeVoiceUser.py
Normal file
327
modules/routes/routeVoiceUser.py
Normal file
|
|
@ -0,0 +1,327 @@
|
||||||
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
|
"""
|
||||||
|
User-scoped voice settings and TTS/STT catalog endpoints.
|
||||||
|
|
||||||
|
Uses modules.interfaces.interfaceVoiceObjects (voice core) and persists preferences
|
||||||
|
via UserVoicePreferences — same domain as routeVoiceGoogle (Google connector ops).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request, status
|
||||||
|
|
||||||
|
from modules.auth import getCurrentUser, limiter
|
||||||
|
from modules.datamodels.datamodelUam import User, UserVoicePreferences
|
||||||
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
|
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/api/voice",
|
||||||
|
tags=["Voice User"],
|
||||||
|
responses={
|
||||||
|
404: {"description": "Not found"},
|
||||||
|
400: {"description": "Bad request"},
|
||||||
|
401: {"description": "Unauthorized"},
|
||||||
|
403: {"description": "Forbidden"},
|
||||||
|
500: {"description": "Internal server error"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/preferences")
|
||||||
|
@limiter.limit("60/minute")
|
||||||
|
def getVoicePreferences(
|
||||||
|
request: Request,
|
||||||
|
currentUser: User = Depends(getCurrentUser),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Get user's voice/language preferences (optionally scoped to mandate via header)."""
|
||||||
|
rootInterface = getRootInterface()
|
||||||
|
mandateId = request.headers.get("X-Mandate-Id") or None
|
||||||
|
userId = str(currentUser.id)
|
||||||
|
|
||||||
|
prefs = rootInterface.db.getRecordset(
|
||||||
|
UserVoicePreferences,
|
||||||
|
recordFilter={"userId": userId, "mandateId": mandateId},
|
||||||
|
)
|
||||||
|
if prefs:
|
||||||
|
return prefs[0] if isinstance(prefs[0], dict) else prefs[0].model_dump()
|
||||||
|
return UserVoicePreferences(userId=userId, mandateId=mandateId).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/preferences")
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
def updateVoicePreferences(
|
||||||
|
request: Request,
|
||||||
|
preferences: Dict[str, Any] = Body(...),
|
||||||
|
currentUser: User = Depends(getCurrentUser),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Update user's voice/language preferences (upsert)."""
|
||||||
|
rootInterface = getRootInterface()
|
||||||
|
mandateId = request.headers.get("X-Mandate-Id") or None
|
||||||
|
userId = str(currentUser.id)
|
||||||
|
|
||||||
|
existing = rootInterface.db.getRecordset(
|
||||||
|
UserVoicePreferences,
|
||||||
|
recordFilter={"userId": userId, "mandateId": mandateId},
|
||||||
|
)
|
||||||
|
|
||||||
|
allowedFields = {
|
||||||
|
"sttLanguage",
|
||||||
|
"ttsLanguage",
|
||||||
|
"ttsVoice",
|
||||||
|
"ttsVoiceMap",
|
||||||
|
"translationSourceLanguage",
|
||||||
|
"translationTargetLanguage",
|
||||||
|
}
|
||||||
|
updateData = {k: v for k, v in preferences.items() if k in allowedFields}
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
existingRecord = existing[0]
|
||||||
|
existingId = existingRecord.get("id") if isinstance(existingRecord, dict) else existingRecord.id
|
||||||
|
rootInterface.db.recordModify(UserVoicePreferences, existingId, updateData)
|
||||||
|
updated = rootInterface.db.getRecordset(UserVoicePreferences, recordFilter={"id": existingId})
|
||||||
|
return updated[0] if updated else {"message": "Updated", **updateData}
|
||||||
|
newPrefs = UserVoicePreferences(userId=userId, mandateId=mandateId, **updateData)
|
||||||
|
created = rootInterface.db.recordCreate(UserVoicePreferences, newPrefs.model_dump())
|
||||||
|
return created if isinstance(created, dict) else created.model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/languages")
|
||||||
|
@limiter.limit("120/minute")
|
||||||
|
async def getVoiceLanguages(
|
||||||
|
request: Request,
|
||||||
|
currentUser: User = Depends(getCurrentUser),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Return available TTS languages (user-level, no instance context needed)."""
|
||||||
|
voiceInterface = getVoiceInterface(currentUser)
|
||||||
|
languagesResult = await voiceInterface.getAvailableLanguages()
|
||||||
|
languageList = languagesResult.get("languages", []) if isinstance(languagesResult, dict) else languagesResult
|
||||||
|
return {"languages": languageList}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/voices")
|
||||||
|
@limiter.limit("120/minute")
|
||||||
|
async def getVoiceVoices(
|
||||||
|
request: Request,
|
||||||
|
language: str = Query("de-DE"),
|
||||||
|
currentUser: User = Depends(getCurrentUser),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Return available TTS voices for a given language."""
|
||||||
|
voiceInterface = getVoiceInterface(currentUser)
|
||||||
|
voicesResult = await voiceInterface.getAvailableVoices(language)
|
||||||
|
voiceList = voicesResult.get("voices", []) if isinstance(voicesResult, dict) else voicesResult
|
||||||
|
return {"voices": voiceList}
|
||||||
|
|
||||||
|
|
||||||
|
# Same minimum as modules.serviceCenter.services.serviceAi.mainServiceAi._checkBillingBeforeAiCall
|
||||||
|
_MIN_AI_BILLING_ESTIMATE_CHF = 0.01
|
||||||
|
|
||||||
|
|
||||||
|
def _userMandateIds(rootInterface, currentUser: User):
|
||||||
|
memberships = rootInterface.getUserMandates(str(currentUser.id))
|
||||||
|
out = []
|
||||||
|
for um in memberships:
|
||||||
|
mid = getattr(um, "mandateId", None) or (um.get("mandateId") if isinstance(um, dict) else None)
|
||||||
|
if mid:
|
||||||
|
out.append(str(mid))
|
||||||
|
return list(dict.fromkeys(out))
|
||||||
|
|
||||||
|
|
||||||
|
def _mandatePassesAiPoolBilling(currentUser: User, mandateId: str, userId: str) -> bool:
|
||||||
|
"""True if mandate pool passes the same billing gate as AI calls (subscription + pool >= estimate)."""
|
||||||
|
from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface
|
||||||
|
|
||||||
|
bi = getBillingInterface(currentUser, mandateId)
|
||||||
|
res = bi.checkBalance(mandateId, userId, _MIN_AI_BILLING_ESTIMATE_CHF)
|
||||||
|
return bool(res.allowed)
|
||||||
|
|
||||||
|
|
||||||
|
def _mandatePoolBalanceChf(currentUser: User, mandateId: str) -> float:
|
||||||
|
from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface
|
||||||
|
|
||||||
|
bi = getBillingInterface(currentUser, mandateId)
|
||||||
|
acc = bi.getMandateAccount(mandateId)
|
||||||
|
if not acc:
|
||||||
|
return 0.0
|
||||||
|
return float(acc.get("balance", 0.0) or 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolveMandateIdForVoiceTestAi(request: Request, currentUser: User) -> str:
|
||||||
|
"""
|
||||||
|
AI sample billing uses mandate pool (PREPAY), not per-user wallet.
|
||||||
|
Prefer X-Mandate-Id when the user is a member and that mandate's pool can pay;
|
||||||
|
otherwise pick the member mandate with the highest pool balance that passes the AI billing check.
|
||||||
|
"""
|
||||||
|
rootInterface = getRootInterface()
|
||||||
|
userId = str(currentUser.id)
|
||||||
|
memberIds = _userMandateIds(rootInterface, currentUser)
|
||||||
|
if not memberIds:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=(
|
||||||
|
"Voice test needs at least one mandate membership for AI billing. "
|
||||||
|
"Join a mandate or open the app from a mandate context."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
headerRaw = (request.headers.get("X-Mandate-Id") or request.headers.get("x-mandate-id") or "").strip()
|
||||||
|
if headerRaw:
|
||||||
|
if headerRaw not in memberIds:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="X-Mandate-Id is not a mandate you belong to.",
|
||||||
|
)
|
||||||
|
if _mandatePassesAiPoolBilling(currentUser, headerRaw, userId):
|
||||||
|
logger.info(
|
||||||
|
"Voice test AI billing: using header mandate %s (pool ok for estimate %.4f CHF)",
|
||||||
|
headerRaw,
|
||||||
|
_MIN_AI_BILLING_ESTIMATE_CHF,
|
||||||
|
)
|
||||||
|
return headerRaw
|
||||||
|
logger.warning(
|
||||||
|
"Voice test AI billing: header mandate %s has insufficient mandate pool or subscription; "
|
||||||
|
"trying other memberships",
|
||||||
|
headerRaw,
|
||||||
|
)
|
||||||
|
|
||||||
|
bestMid = None
|
||||||
|
bestBal = -1.0
|
||||||
|
for mid in memberIds:
|
||||||
|
if not _mandatePassesAiPoolBilling(currentUser, mid, userId):
|
||||||
|
continue
|
||||||
|
bal = _mandatePoolBalanceChf(currentUser, mid)
|
||||||
|
if bal > bestBal:
|
||||||
|
bestBal = bal
|
||||||
|
bestMid = mid
|
||||||
|
|
||||||
|
if bestMid:
|
||||||
|
logger.info(
|
||||||
|
"Voice test AI billing: selected mandate %s (mandate pool %.2f CHF, estimate %.4f CHF)",
|
||||||
|
bestMid,
|
||||||
|
bestBal,
|
||||||
|
_MIN_AI_BILLING_ESTIMATE_CHF,
|
||||||
|
)
|
||||||
|
return bestMid
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||||
|
detail=(
|
||||||
|
"No mandate you belong to has sufficient shared pool balance for AI (or subscription inactive). "
|
||||||
|
"Top up the mandate pool or use a mandate with budget."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitizeAiTtsSample(raw: str) -> str:
|
||||||
|
s = (raw or "").strip()
|
||||||
|
if s.startswith("```"):
|
||||||
|
nl = s.find("\n")
|
||||||
|
if nl != -1:
|
||||||
|
s = s[nl + 1 :]
|
||||||
|
if s.rstrip().endswith("```"):
|
||||||
|
s = s.rstrip()[:-3].strip()
|
||||||
|
if len(s) >= 2 and ((s[0] == s[-1] == '"') or (s[0] == s[-1] == "'")):
|
||||||
|
s = s[1:-1].strip()
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
async def _generateTtsSampleTextForLocale(
|
||||||
|
request: Request,
|
||||||
|
currentUser: User,
|
||||||
|
localeTag: str,
|
||||||
|
) -> str:
|
||||||
|
from modules.serviceCenter import getService
|
||||||
|
from modules.serviceCenter.context import ServiceCenterContext
|
||||||
|
from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, PriorityEnum, ProcessingModeEnum
|
||||||
|
from modules.serviceCenter.services.serviceBilling.mainServiceBilling import (
|
||||||
|
BillingContextError,
|
||||||
|
InsufficientBalanceException,
|
||||||
|
ProviderNotAllowedException,
|
||||||
|
)
|
||||||
|
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionInactiveException
|
||||||
|
|
||||||
|
mandateId = _resolveMandateIdForVoiceTestAi(request, currentUser)
|
||||||
|
ctx = ServiceCenterContext(user=currentUser, mandate_id=mandateId, feature_instance_id=None)
|
||||||
|
aiService = getService("ai", ctx)
|
||||||
|
|
||||||
|
systemPrompt = (
|
||||||
|
"You write short text-to-speech demo lines for end users.\n"
|
||||||
|
"Task: Output exactly one or two natural sentences a user would enjoy hearing when testing a voice.\n"
|
||||||
|
"The entire output MUST be written ONLY in the natural spoken language that matches the given "
|
||||||
|
"BCP-47 locale tag. Do not use any other language.\n"
|
||||||
|
"Do not mention locales, tags, tests, artificial intelligence, or these instructions.\n"
|
||||||
|
"No quotation marks around the text. No markdown. Plain text only."
|
||||||
|
)
|
||||||
|
userPrompt = f"BCP-47 locale tag: `{localeTag}`.\nWrite the sample now."
|
||||||
|
|
||||||
|
aiRequest = AiCallRequest(
|
||||||
|
prompt=userPrompt,
|
||||||
|
context=systemPrompt,
|
||||||
|
requireNeutralization=False,
|
||||||
|
options=AiCallOptions(
|
||||||
|
operationType=OperationTypeEnum.DATA_GENERATE,
|
||||||
|
priority=PriorityEnum.SPEED,
|
||||||
|
processingMode=ProcessingModeEnum.BASIC,
|
||||||
|
compressPrompt=False,
|
||||||
|
compressContext=False,
|
||||||
|
temperature=0.75,
|
||||||
|
maxParts=1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
response = await aiService.callAi(aiRequest)
|
||||||
|
except SubscriptionInactiveException as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=e.message) from e
|
||||||
|
except InsufficientBalanceException as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_402_PAYMENT_REQUIRED, detail=str(e)) from e
|
||||||
|
except ProviderNotAllowedException as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=getattr(e, "message", None) or str(e),
|
||||||
|
) from e
|
||||||
|
except BillingContextError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||||
|
|
||||||
|
content = _sanitizeAiTtsSample(getattr(response, "content", None) or "")
|
||||||
|
if getattr(response, "errorCount", 0) or not content:
|
||||||
|
logger.warning("Voice test AI sample empty or errorCount=%s", getattr(response, "errorCount", None))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail="Could not generate voice test sample text.",
|
||||||
|
)
|
||||||
|
if len(content) > 500:
|
||||||
|
content = content[:500].rstrip()
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test")
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
async def testVoice(
|
||||||
|
request: Request,
|
||||||
|
body: Dict[str, Any] = Body(...),
|
||||||
|
currentUser: User = Depends(getCurrentUser),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Test a specific voice. Sample text is AI-generated in the voice locale unless `text` is supplied."""
|
||||||
|
textRaw = body.get("text")
|
||||||
|
language = body.get("language", "de-DE")
|
||||||
|
voiceId = body.get("voiceId")
|
||||||
|
|
||||||
|
text = (textRaw or "").strip() if isinstance(textRaw, str) else ""
|
||||||
|
if not text:
|
||||||
|
text = await _generateTtsSampleTextForLocale(request, currentUser, language)
|
||||||
|
|
||||||
|
voiceInterface = getVoiceInterface(currentUser)
|
||||||
|
result = await voiceInterface.textToSpeech(text=text, languageCode=language, voiceName=voiceId)
|
||||||
|
if result and isinstance(result, dict):
|
||||||
|
audioContent = result.get("audioContent")
|
||||||
|
if audioContent:
|
||||||
|
audioB64 = base64.b64encode(
|
||||||
|
audioContent if isinstance(audioContent, bytes) else audioContent.encode()
|
||||||
|
).decode()
|
||||||
|
return {"success": True, "audio": audioB64, "format": "mp3", "text": text}
|
||||||
|
return {"success": False, "error": "TTS returned no audio"}
|
||||||
|
|
@ -21,6 +21,8 @@ class ServiceCenterContext:
|
||||||
workflow_id: Optional[str] = None
|
workflow_id: Optional[str] = None
|
||||||
workflow: Any = None
|
workflow: Any = None
|
||||||
requireNeutralization: Optional[bool] = 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
|
@property
|
||||||
def mandateId(self) -> Optional[str]:
|
def mandateId(self) -> Optional[str]:
|
||||||
|
|
|
||||||
|
|
@ -322,7 +322,7 @@ class AgentService:
|
||||||
|
|
||||||
def _createAiCallFn(self) -> Callable[[AiCallRequest], AiCallResponse]:
|
def _createAiCallFn(self) -> Callable[[AiCallRequest], AiCallResponse]:
|
||||||
"""Create the AI call function that wraps serviceAi with billing."""
|
"""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:
|
async def _aiCallFn(request: AiCallRequest) -> AiCallResponse:
|
||||||
if ctxNeutralization is not None and request.requireNeutralization is None:
|
if ctxNeutralization is not None and request.requireNeutralization is None:
|
||||||
request.requireNeutralization = ctxNeutralization
|
request.requireNeutralization = ctxNeutralization
|
||||||
|
|
@ -332,7 +332,7 @@ class AgentService:
|
||||||
|
|
||||||
def _createAiCallStreamFn(self):
|
def _createAiCallStreamFn(self):
|
||||||
"""Create the streaming AI call function. Yields str deltas, then AiCallResponse."""
|
"""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):
|
async def _aiCallStreamFn(request: AiCallRequest):
|
||||||
if ctxNeutralization is not None and request.requireNeutralization is None:
|
if ctxNeutralization is not None and request.requireNeutralization is None:
|
||||||
request.requireNeutralization = ctxNeutralization
|
request.requireNeutralization = ctxNeutralization
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,9 @@ class _ServicesAdapter:
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def featureCode(self) -> Optional[str]:
|
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
|
w = self.workflow
|
||||||
if w and hasattr(w, "feature") and w.feature:
|
if w and hasattr(w, "feature") and w.feature:
|
||||||
return getattr(w.feature, "code", None)
|
return getattr(w.feature, "code", None)
|
||||||
|
|
@ -742,9 +745,8 @@ detectedIntent-Werte:
|
||||||
|
|
||||||
balance_str = f"{(balanceCheck.currentBalance or 0):.2f}"
|
balance_str = f"{(balanceCheck.currentBalance or 0):.2f}"
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Billing check failed for user {user.id}: "
|
f"AI billing check failed (mandate pool): mandate={mandateId} user={user.id} "
|
||||||
f"Balance {balance_str} CHF, "
|
f"poolBalance={balance_str} CHF required~={estimatedCost:.4f} CHF reason={reason}"
|
||||||
f"Reason: {reason}"
|
|
||||||
)
|
)
|
||||||
ulabel = (getattr(user, "email", None) or getattr(user, "username", None) or str(user.id))
|
ulabel = (getattr(user, "email", None) or getattr(user, "username", None) or str(user.id))
|
||||||
maybeEmailMandatePoolExhausted(
|
maybeEmailMandatePoolExhausted(
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,9 @@ def getService(currentUser: User, mandateId: str, featureInstanceId: str = None,
|
||||||
|
|
||||||
def _get_feature_code_from_context(context) -> Optional[str]:
|
def _get_feature_code_from_context(context) -> Optional[str]:
|
||||||
"""Extract featureCode from ServiceCenterContext."""
|
"""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:
|
if context.workflow and hasattr(context.workflow, "feature") and context.workflow.feature:
|
||||||
return getattr(context.workflow.feature, "code", None)
|
return getattr(context.workflow.feature, "code", None)
|
||||||
return getattr(context.workflow, "featureCode", None) if context.workflow else None
|
return getattr(context.workflow, "featureCode", None) if context.workflow else None
|
||||||
|
|
|
||||||
|
|
@ -210,6 +210,13 @@ class KnowledgeService:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Could not set neutralizationStatus for file {fileId}: {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")
|
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
|
return index
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
|
||||||
|
|
@ -99,25 +99,44 @@ try:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not refresh APP_CONFIG: {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 = [
|
ALL_DATABASES = [
|
||||||
"poweron_app", # Haupt-App: User, Mandate, RBAC, Features
|
"poweron_app",
|
||||||
"poweron_chat", # Chat-Konversationen
|
"poweron_automation",
|
||||||
"poweron_chatbot", # Chatbot-Feature: Konversationen, Nachrichten, Logs
|
"poweron_automation2",
|
||||||
"poweron_management", # Workflows, Prompts, Connections
|
"poweron_billing",
|
||||||
"poweron_realestate", # Real Estate
|
"poweron_chat",
|
||||||
"poweron_trustee", # Trustee
|
"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
|
# Datenbank-Konfiguration: Mapping von DB-Name zu Config-Prefix
|
||||||
# Jede Datenbank hat ihre eigenen Variablen: DB_APP_HOST, DB_CHAT_HOST, etc.
|
# 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 = {
|
DATABASE_CONFIG = {
|
||||||
"poweron_app": "DB_APP", # DB_APP_HOST, DB_APP_USER, DB_APP_PASSWORD_SECRET, etc.
|
"poweron_app": "DB_APP",
|
||||||
"poweron_chat": "DB_CHAT", # DB_CHAT_HOST, DB_CHAT_USER, etc.
|
"poweron_chat": "DB_CHAT",
|
||||||
"poweron_chatbot": "DB_CHATBOT", # DB_CHATBOT_* (fallsback to DB_*)
|
"poweron_chatbot": "DB_CHATBOT",
|
||||||
"poweron_management": "DB_MANAGEMENT",
|
"poweron_management": "DB_MANAGEMENT",
|
||||||
"poweron_realestate": "DB_REALESTATE",
|
"poweron_realestate": "DB_REALESTATE",
|
||||||
"poweron_trustee": "DB_TRUSTEE",
|
"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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -284,10 +284,13 @@ except Exception as e:
|
||||||
print(f" [FAIL] Fix 5: {e}")
|
print(f" [FAIL] Fix 5: {e}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
voiceUserPath = os.path.join(
|
||||||
"modules", "routes", "routeSecurityLocal.py"), "r") as f:
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||||
|
"modules", "routes", "routeVoiceUser.py",
|
||||||
|
)
|
||||||
|
with open(voiceUserPath, "r") as f:
|
||||||
source = f.read()
|
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)
|
_check("Voice preferences PUT endpoint", "updateVoicePreferences" in source)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
errors.append(f"Fix 5 Routes: {e}")
|
errors.append(f"Fix 5 Routes: {e}")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue