From df9a43c190f4f283e666d9772dfc5e0f98cf6529 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sun, 10 May 2026 22:09:51 +0200 Subject: [PATCH] abo enterprise, ai agent fixes --- app.py | 4 + modules/aicore/aicoreModelSelector.py | 4 +- modules/datamodels/datamodelSubscription.py | 85 +++- modules/features/commcoach/mainCommcoach.py | 7 - .../features/commcoach/serviceCommcoach.py | 12 +- modules/interfaces/interfaceDbBilling.py | 55 ++- modules/interfaces/interfaceDbManagement.py | 31 +- modules/interfaces/interfaceDbSubscription.py | 31 +- modules/routes/routeSubscription.py | 172 +++++++- .../services/serviceAgent/agentLoop.py | 79 +++- .../services/serviceAgent/datamodelAgent.py | 14 +- .../renderers/documentRendererBaseTemplate.py | 24 +- .../renderers/rendererHtml.py | 27 +- .../renderers/rendererMarkdown.py | 3 +- .../renderers/rendererPdf.py | 24 +- .../renderers/rendererText.py | 7 +- .../serviceGeneration/styleDefaults.py | 10 +- .../enterpriseRenewalScheduler.py | 89 +++++ .../mainServiceSubscription.py | 378 +++++++++++++++--- .../serviceGeneration/test_style_resolver.py | 7 + 20 files changed, 902 insertions(+), 161 deletions(-) create mode 100644 modules/serviceCenter/services/serviceSubscription/enterpriseRenewalScheduler.py diff --git a/app.py b/app.py index 1f8f87f9..f5adb3d7 100644 --- a/app.py +++ b/app.py @@ -396,6 +396,10 @@ async def lifespan(app: FastAPI): from modules.shared.auditLogger import registerAuditLogCleanupScheduler registerAuditLogCleanupScheduler() + # Register enterprise subscription auto-renewal scheduler + from modules.serviceCenter.services.serviceSubscription.enterpriseRenewalScheduler import registerEnterpriseRenewalScheduler + registerEnterpriseRenewalScheduler() + # Recover background jobs that were RUNNING when the previous worker died try: from modules.serviceCenter.services.serviceBackgroundJobs.mainBackgroundJobService import ( diff --git a/modules/aicore/aicoreModelSelector.py b/modules/aicore/aicoreModelSelector.py index d3df1e45..d04472cd 100644 --- a/modules/aicore/aicoreModelSelector.py +++ b/modules/aicore/aicoreModelSelector.py @@ -272,7 +272,9 @@ class ModelSelector: return 1.0 elif requestedPriority == PriorityEnum.SPEED: - return model.speedRating / 10.0 + # Scale to same magnitude as operation type (x1000) so speed + # can meaningfully influence model ranking across tiers. + return model.speedRating * 100.0 elif requestedPriority == PriorityEnum.QUALITY: return model.qualityRating / 10.0 diff --git a/modules/datamodels/datamodelSubscription.py b/modules/datamodels/datamodelSubscription.py index 847285cd..4196a959 100644 --- a/modules/datamodels/datamodelSubscription.py +++ b/modules/datamodels/datamodelSubscription.py @@ -6,7 +6,7 @@ StripePlanPrice (persisted Stripe IDs per plan). State Machine: see wiki/concepts/Subscription-State-Machine.md """ -from typing import Dict, List, Optional +from typing import Any, Dict, List, Optional from enum import Enum from datetime import datetime, timezone from pydantic import BaseModel, Field @@ -284,12 +284,63 @@ class MandateSubscription(PowerOnModel): json_schema_extra={"label": "Stripe-Item (Instanzen)"}, ) + # Enterprise subscription fields (custom limits, no Stripe billing) + isEnterprise: bool = Field( + default=False, + description="True for enterprise subscriptions managed by sysadmin with flat pricing", + json_schema_extra={"label": "Enterprise-Abo"}, + ) + enterpriseFlatPriceCHF: Optional[float] = Field( + None, + description="Flat price per period (CHF) for enterprise subscriptions", + json_schema_extra={"label": "Pauschale (CHF)"}, + ) + enterpriseMaxUsers: Optional[int] = Field( + None, + description="Custom user limit for enterprise (None = unlimited)", + json_schema_extra={"label": "Enterprise Max. Benutzer"}, + ) + enterpriseMaxFeatureInstances: Optional[int] = Field( + None, + description="Custom feature instance limit for enterprise (None = unlimited)", + json_schema_extra={"label": "Enterprise Max. Module"}, + ) + enterpriseMaxDataVolumeMB: Optional[int] = Field( + None, + description="Custom storage limit in MB for enterprise (None = unlimited)", + json_schema_extra={"label": "Enterprise Datenvolumen (MB)"}, + ) + enterpriseBudgetAiCHF: Optional[float] = Field( + None, + description="Fixed AI budget per period (CHF) for enterprise subscriptions", + json_schema_extra={"label": "Enterprise AI-Budget (CHF)"}, + ) + enterpriseNote: Optional[str] = Field( + None, + description="Free-text note (e.g. contract reference) for enterprise subscriptions", + json_schema_extra={"label": "Enterprise Notiz"}, + ) + # ============================================================================ # Built-in plan catalog (static, no env dependency) # ============================================================================ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = { + "ENTERPRISE": SubscriptionPlan( + planKey="ENTERPRISE", + selectableByUser=False, + title=t("Enterprise"), + description=t("Individuelles Pauschalen-Abonnement — Limiten und Preis vom Sysadmin festgelegt."), + billingPeriod=BillingPeriodEnum.NONE, + autoRenew=False, + maxUsers=None, + maxFeatureInstances=None, + includedModules=0, + maxDataVolumeMB=None, + budgetAiCHF=0.0, + budgetAiPerUserCHF=0.0, + ), "ROOT": SubscriptionPlan( planKey="ROOT", selectableByUser=False, @@ -415,3 +466,35 @@ def getPlan(planKey: str) -> Optional[SubscriptionPlan]: def _getSelectablePlans() -> List[SubscriptionPlan]: """Return plans that users can choose in the UI.""" return [p for p in BUILTIN_PLANS.values() if p.selectableByUser] + + +def getEffectiveLimits(sub: Dict[str, Any], plan: Optional[SubscriptionPlan] = None) -> Dict[str, Any]: + """Resolve effective limits for a subscription. + + For enterprise subscriptions the custom enterprise* fields on the subscription + record take precedence. For standard subscriptions the plan catalog values are + returned. Falls back to unlimited (None / 0) when neither source provides a + value.""" + if sub.get("isEnterprise"): + return { + "maxUsers": sub.get("enterpriseMaxUsers"), + "maxFeatureInstances": sub.get("enterpriseMaxFeatureInstances"), + "maxDataVolumeMB": sub.get("enterpriseMaxDataVolumeMB"), + "budgetAiCHF": sub.get("enterpriseBudgetAiCHF") or 0.0, + "includedModules": sub.get("enterpriseMaxFeatureInstances") or 0, + } + if plan: + return { + "maxUsers": plan.maxUsers, + "maxFeatureInstances": plan.maxFeatureInstances, + "maxDataVolumeMB": plan.maxDataVolumeMB, + "budgetAiCHF": plan.budgetAiCHF, + "includedModules": plan.includedModules, + } + return { + "maxUsers": None, + "maxFeatureInstances": None, + "maxDataVolumeMB": None, + "budgetAiCHF": 0.0, + "includedModules": 0, + } diff --git a/modules/features/commcoach/mainCommcoach.py b/modules/features/commcoach/mainCommcoach.py index 6beede11..999f940c 100644 --- a/modules/features/commcoach/mainCommcoach.py +++ b/modules/features/commcoach/mainCommcoach.py @@ -37,11 +37,6 @@ UI_OBJECTS = [ "label": t("Session", context="UI"), "meta": {"area": "session"} }, - { - "objectKey": "ui.feature.commcoach.dossier", - "label": t("Dossier", context="UI"), - "meta": {"area": "dossier"} - }, { "objectKey": "ui.feature.commcoach.settings", "label": t("Einstellungen", context="UI"), @@ -199,7 +194,6 @@ TEMPLATE_ROLES = [ {"context": "UI", "item": "ui.feature.commcoach.assistant", "view": True}, {"context": "UI", "item": "ui.feature.commcoach.modules", "view": True}, {"context": "UI", "item": "ui.feature.commcoach.session", "view": True}, - {"context": "UI", "item": "ui.feature.commcoach.dossier", "view": True}, {"context": "UI", "item": "ui.feature.commcoach.settings", "view": True}, {"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"}, {"context": "RESOURCE", "item": None, "view": False}, @@ -213,7 +207,6 @@ TEMPLATE_ROLES = [ {"context": "UI", "item": "ui.feature.commcoach.assistant", "view": True}, {"context": "UI", "item": "ui.feature.commcoach.modules", "view": True}, {"context": "UI", "item": "ui.feature.commcoach.session", "view": True}, - {"context": "UI", "item": "ui.feature.commcoach.dossier", "view": True}, {"context": "UI", "item": "ui.feature.commcoach.settings", "view": True}, {"context": "DATA", "item": "data.feature.commcoach.TrainingModule", "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"}, {"context": "DATA", "item": "data.feature.commcoach.CoachingSession", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"}, diff --git a/modules/features/commcoach/serviceCommcoach.py b/modules/features/commcoach/serviceCommcoach.py index 821fb291..39b96b55 100644 --- a/modules/features/commcoach/serviceCommcoach.py +++ b/modules/features/commcoach/serviceCommcoach.py @@ -690,7 +690,7 @@ def _buildConversationHistory(messages: List[Dict[str, Any]]) -> List[Dict[str, return history -_TTS_WORD_LIMIT = 200 +_TTS_WORD_LIMIT = 80 async def _prepareSpeechText(fullText: str, callAiFn) -> str: @@ -906,10 +906,14 @@ class CommcoachService: ) agentService = getService("agent", serviceContext) + from modules.datamodels.datamodelAi import PriorityEnum, OperationTypeEnum config = AgentConfig( toolSet="commcoach" if useTools else "none", maxRounds=3 if useTools else 1, temperature=0.4, + excludeAllTools=not useTools, + priority=PriorityEnum.SPEED if not useTools else None, + operationType=OperationTypeEnum.DATA_QUERY if not useTools else None, ) buildRagContextFn = _createCommcoachRagFn( @@ -989,10 +993,14 @@ class CommcoachService: ) isFirstSession = not previousSessionSummaries or len(previousSessionSummaries) == 0 + logger.info(f"Session opening {sessionId}: isFirstSession={isFirstSession}, previousSessions={len(previousSessionSummaries) if previousSessionSummaries else 0}, persona={persona.get('key') if persona else None}") if persona and persona.get("key") != "coach": personaLabel = persona.get("label", "Gesprächspartner") - openingUserPrompt = f"Beginne das Gespräch in deiner Rolle als {personaLabel}. Stelle dich kurz vor und eröffne die Situation gemäss deiner Rollenbeschreibung." + if isFirstSession: + openingUserPrompt = f"Beginne das Gespräch in deiner Rolle als {personaLabel}. Stelle dich kurz vor und eröffne die Situation gemäss deiner Rollenbeschreibung." + else: + openingUserPrompt = f"Du bist weiterhin in deiner Rolle als {personaLabel}. Der Benutzer kehrt zu einem Folgegespräch zurück. Begrüsse ihn kurz zurück, beziehe dich auf das letzte Gespräch (siehe bisherige Sessions) und knüpfe dort an. Stelle dich NICHT erneut vor." elif isFirstSession: openingUserPrompt = "Dies ist die ERSTE Session zu diesem Thema. Begrüsse den Benutzer, stelle das Thema kurz vor und stelle eine offene Einstiegsfrage. Erfinde KEINE vorherigen Gespräche oder Zusammenfassungen." else: diff --git a/modules/interfaces/interfaceDbBilling.py b/modules/interfaces/interfaceDbBilling.py index fcb559aa..25f022af 100644 --- a/modules/interfaces/interfaceDbBilling.py +++ b/modules/interfaces/interfaceDbBilling.py @@ -908,18 +908,22 @@ class BillingObjects: ) def reconcileMandateStorageBilling(self, mandateId: str) -> Optional[Dict[str, Any]]: - """Debit prepay pool for new storage overage using period high-watermark (no credit on delete).""" + """Debit prepay pool for new storage overage using period high-watermark (no credit on delete). + Skipped for enterprise subscriptions (hard-block via assertCapacity instead).""" settings = self.getSettings(mandateId) if not settings: return None from modules.interfaces.interfaceDbSubscription import getRootInterface as _getSubRoot - from modules.datamodels.datamodelSubscription import getPlan + from modules.datamodels.datamodelSubscription import getPlan, getEffectiveLimits subIface = _getSubRoot() usedMB = float(subIface.getMandateDataVolumeMB(mandateId)) sub = subIface.getOperativeForMandate(mandateId) + if sub and sub.get("isEnterprise"): + return None plan = getPlan(sub.get("planKey", "")) if sub else None - includedMB = plan.maxDataVolumeMB if plan and plan.maxDataVolumeMB is not None else None + limits = getEffectiveLimits(sub, plan) if sub else {} + includedMB = limits.get("maxDataVolumeMB") if includedMB is None: return None @@ -966,27 +970,37 @@ class BillingObjects: # Subscription AI-Budget Credit # ========================================================================= - def creditSubscriptionBudget(self, mandateId: str, planKey: str, periodLabel: str = "") -> Optional[Dict[str, Any]]: + def creditSubscriptionBudget( + self, mandateId: str, planKey: str, periodLabel: str = "", + enterpriseBudgetOverride: Optional[float] = None, + ) -> Optional[Dict[str, Any]]: """Credit AI budget to the mandate pool account. - Amount = budgetAiPerUserCHF * activeUsers (dynamic, not the static plan.budgetAiCHF). + For standard plans: amount = budgetAiPerUserCHF * activeUsers. + For enterprise: uses the fixed ``enterpriseBudgetOverride`` amount. Should be called once per billing period (initial activation + each invoice.paid). Returns the created CREDIT transaction or None if budget is 0.""" - from modules.datamodels.datamodelSubscription import getPlan + if enterpriseBudgetOverride is not None and enterpriseBudgetOverride > 0: + amount = enterpriseBudgetOverride + description = f"AI-Budget Enterprise ({planKey})" + if periodLabel: + description += f" – {periodLabel}" + else: + from modules.datamodels.datamodelSubscription import getPlan - plan = getPlan(planKey) - if not plan or not plan.budgetAiPerUserCHF or plan.budgetAiPerUserCHF <= 0: - return None + plan = getPlan(planKey) + if not plan or not plan.budgetAiPerUserCHF or plan.budgetAiPerUserCHF <= 0: + return None - from modules.interfaces.interfaceDbSubscription import getRootInterface as _getSubRoot - subRoot = _getSubRoot() - activeUsers = max(subRoot.countActiveUsers(mandateId), 1) - amount = plan.budgetAiPerUserCHF * activeUsers + from modules.interfaces.interfaceDbSubscription import getRootInterface as _getSubRoot + subRoot = _getSubRoot() + activeUsers = max(subRoot.countActiveUsers(mandateId), 1) + amount = plan.budgetAiPerUserCHF * activeUsers + description = f"AI-Budget ({planKey}, {activeUsers} User)" + if periodLabel: + description += f" – {periodLabel}" poolAccount = self.getOrCreateMandateAccount(mandateId) - description = f"AI-Budget ({planKey}, {activeUsers} User)" - if periodLabel: - description += f" – {periodLabel}" transaction = BillingTransaction( accountId=poolAccount["id"], @@ -998,8 +1012,8 @@ class BillingObjects: ) created = self.createTransaction(transaction) logger.info( - "AI-Budget credited mandate=%s plan=%s users=%d amount=%.2f CHF", - mandateId, planKey, activeUsers, amount, + "AI-Budget credited mandate=%s plan=%s amount=%.2f CHF", + mandateId, planKey, amount, ) return created @@ -1027,7 +1041,8 @@ class BillingObjects: delta > 0: user added -> CREDIT pro-rata portion delta < 0: user removed -> DEBIT pro-rata portion - """ + + Skipped for enterprise subscriptions (fixed budget, no pro-rata).""" from modules.datamodels.datamodelSubscription import getPlan plan = getPlan(planKey) @@ -1039,6 +1054,8 @@ class BillingObjects: operative = subRoot.getOperativeForMandate(mandateId) if not operative: return None + if operative.get("isEnterprise"): + return None periodStart = operative.get("currentPeriodStart") periodEnd = operative.get("currentPeriodEnd") diff --git a/modules/interfaces/interfaceDbManagement.py b/modules/interfaces/interfaceDbManagement.py index e212d502..f74de871 100644 --- a/modules/interfaces/interfaceDbManagement.py +++ b/modules/interfaces/interfaceDbManagement.py @@ -37,8 +37,6 @@ logger = logging.getLogger(__name__) managementDatabase = "poweron_management" registerDatabase(managementDatabase) -# Singleton factory for Management instances with AI service per context -_instancesManagement = {} # Custom exceptions for file handling class FileError(Exception): @@ -124,14 +122,12 @@ class ComponentObjects: return None def __del__(self): - """Cleanup method to close database connection.""" + """Release the database connector reference (shared connectors stay open).""" if hasattr(self, 'db') and self.db is not None: try: self.db.close() - except Exception as e: - logger.error(f"Error closing database connection: {e}") - - logger.debug(f"User context set: userId={self.userId}") + except Exception: + pass def _initializeDatabase(self): """Initializes the database connection directly.""" @@ -2273,10 +2269,11 @@ class ComponentObjects: def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> 'ComponentObjects': """ - Returns a ComponentObjects instance. - If currentUser is provided, initializes with user context. - Otherwise, returns an instance with only database access. - + Returns a ComponentObjects instance scoped to the given user/mandate/featureInstance. + + Each call creates a lightweight instance whose DB connector is already + cached inside ``getCachedConnector``, so the overhead is minimal. + Args: currentUser: The authenticated user mandateId: The mandate ID from RequestContext (X-Mandate-Id header). Required. @@ -2284,16 +2281,12 @@ def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = """ effectiveMandateId = str(mandateId) if mandateId else None effectiveFeatureInstanceId = str(featureInstanceId) if featureInstanceId else None - - # Create new instance if not exists - if "default" not in _instancesManagement: - _instancesManagement["default"] = ComponentObjects() - - interface = _instancesManagement["default"] - + + interface = ComponentObjects() + if currentUser: interface.setUserContext(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId) else: logger.info("Returning interface without user context") - + return interface \ No newline at end of file diff --git a/modules/interfaces/interfaceDbSubscription.py b/modules/interfaces/interfaceDbSubscription.py index a39685fc..a0a69315 100644 --- a/modules/interfaces/interfaceDbSubscription.py +++ b/modules/interfaces/interfaceDbSubscription.py @@ -27,6 +27,7 @@ from modules.datamodels.datamodelSubscription import ( BUILTIN_PLANS, getPlan as getPlanFromCatalog, _getSelectablePlans, + getEffectiveLimits, ) logger = logging.getLogger(__name__) @@ -276,33 +277,42 @@ class SubscriptionObjects: ) plan = self.getPlan(sub.get("planKey", "")) - if not plan: - return True + limits = getEffectiveLimits(sub, plan) + isEnterprise = sub.get("isEnterprise", False) if resourceType == "users": - cap = plan.maxUsers + cap = limits["maxUsers"] if cap is None: return True current = self.countActiveUsers(mandateId) if current + delta > cap: from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException - raise SubscriptionCapacityException(resourceType=resourceType, currentCount=current, maxAllowed=cap) + raise SubscriptionCapacityException( + resourceType=resourceType, currentCount=current, maxAllowed=cap, + isEnterprise=isEnterprise, + ) elif resourceType == "featureInstances": - cap = plan.maxFeatureInstances + cap = limits["maxFeatureInstances"] if cap is None: return True current = self.countActiveFeatureInstances(mandateId) if current + delta > cap: from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException - raise SubscriptionCapacityException(resourceType=resourceType, currentCount=current, maxAllowed=cap) + raise SubscriptionCapacityException( + resourceType=resourceType, currentCount=current, maxAllowed=cap, + isEnterprise=isEnterprise, + ) elif resourceType == "dataVolumeMB": - cap = plan.maxDataVolumeMB + cap = limits["maxDataVolumeMB"] if cap is None: return True 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) + raise SubscriptionCapacityException( + resourceType=resourceType, currentCount=int(currentMB), maxAllowed=cap, + isEnterprise=isEnterprise, + ) return True @@ -325,10 +335,11 @@ class SubscriptionObjects: if not sub: return None plan = self.getPlan(sub.get("planKey", "")) - if not plan or not plan.maxDataVolumeMB: + limits = getEffectiveLimits(sub, plan) + limitMB = limits["maxDataVolumeMB"] + if not limitMB: return None usedMB = self.getMandateDataVolumeMB(mandateId) - limitMB = plan.maxDataVolumeMB percent = (usedMB / limitMB * 100) if limitMB > 0 else 0 if percent >= 80: return {"usedMB": round(usedMB, 2), "limitMB": limitMB, "percent": round(percent, 1), "warning": True} diff --git a/modules/routes/routeSubscription.py b/modules/routes/routeSubscription.py index 22beef42..9c5ecb01 100644 --- a/modules/routes/routeSubscription.py +++ b/modules/routes/routeSubscription.py @@ -106,13 +106,14 @@ class SubscriptionStatusResponse(BaseModel): usage: Optional[SubscriptionUsage] = None -def _computeUsage(mandateId: str, plan) -> SubscriptionUsage: +def _computeUsage(mandateId: str, plan, operative: Optional[Dict[str, Any]] = None) -> SubscriptionUsage: """Compute current usage metrics for a mandate's subscription.""" try: from modules.interfaces.interfaceDbApp import getRootInterface from modules.datamodels.datamodelMembership import UserMandate from modules.datamodels.datamodelFeatures import FeatureInstance from modules.interfaces.interfaceDbKnowledge import aggregateMandateRagTotalBytes + from modules.datamodels.datamodelSubscription import getEffectiveLimits rootIf = getRootInterface() @@ -128,7 +129,8 @@ def _computeUsage(mandateId: str, plan) -> SubscriptionUsage: ragBytes = aggregateMandateRagTotalBytes(mandateId) usedMB = round(ragBytes / (1024 * 1024), 2) - maxMB = plan.maxDataVolumeMB if plan else None + limits = getEffectiveLimits(operative, plan) if operative else {} + maxMB = limits.get("maxDataVolumeMB") if limits else (plan.maxDataVolumeMB if plan else None) storagePercent = round((usedMB / maxMB) * 100, 1) if maxMB else None return SubscriptionUsage( @@ -207,7 +209,7 @@ def getStatus(request: Request, context: RequestContext = Depends(getRequestCont plan = subService.getPlan(operative.get("planKey", "")) - usage = _computeUsage(mandateId, plan) + usage = _computeUsage(mandateId, plan, operative) return SubscriptionStatusResponse( active=True, @@ -451,13 +453,16 @@ def _buildEnrichedSubscriptions() -> List[Dict[str, Any]]: sub["planTitle"] = resolveText(plan.title) if plan else planKey if sub.get("status") in operativeValues: - userPrice = sub.get("snapshotPricePerUserCHF", 0) or 0 - instPrice = sub.get("snapshotPricePerInstanceCHF", 0) or 0 userCount = userCountMap.get(mid, 0) instanceCount = instanceCountMap.get(mid, 0) - includedModules = plan.includedModules if plan else 0 - billableModules = max(0, instanceCount - includedModules) - sub["monthlyRevenueCHF"] = round(userPrice * userCount + instPrice * billableModules, 2) + if sub.get("isEnterprise"): + sub["monthlyRevenueCHF"] = round(sub.get("enterpriseFlatPriceCHF") or 0, 2) + else: + userPrice = sub.get("snapshotPricePerUserCHF", 0) or 0 + instPrice = sub.get("snapshotPricePerInstanceCHF", 0) or 0 + includedModules = plan.includedModules if plan else 0 + billableModules = max(0, instanceCount - includedModules) + sub["monthlyRevenueCHF"] = round(userPrice * userCount + instPrice * billableModules, 2) sub["activeUsers"] = userCount sub["activeInstances"] = instanceCount else: @@ -570,13 +575,16 @@ def _getDataVolumeUsage( ragBytes = aggregateMandateRagTotalBytes(mandateId) ragMB = round(ragBytes / (1024 * 1024), 2) + from modules.datamodels.datamodelSubscription import getEffectiveLimits + maxMB = None subIf = _getSubRootIf() operative = subIf.getOperativeForMandate(mandateId) if operative: plan = subIf.getPlan(operative.get("planKey") or "") - if plan and plan.maxDataVolumeMB is not None: - maxMB = int(plan.maxDataVolumeMB) + limits = getEffectiveLimits(operative, plan) + if limits["maxDataVolumeMB"] is not None: + maxMB = int(limits["maxDataVolumeMB"]) usedMB = ragMB percentUsed = round((usedMB / maxMB) * 100, 1) if maxMB else None @@ -593,3 +601,147 @@ def _getDataVolumeUsage( "percentUsed": percentUsed, "warning": (percentUsed or 0) >= 80, } + + +# ============================================================================= +# Enterprise Subscription (SysAdmin-only) +# ============================================================================= + +class EnterpriseCreateRequest(BaseModel): + mandateId: str = Field(..., description="Target mandate ID") + startDate: float = Field(..., description="Period start (UTC unix timestamp)") + endDate: float = Field(..., description="Period end (UTC unix timestamp)") + autoRenew: bool = Field(default=False, description="Auto-renew at period end") + flatPriceCHF: float = Field(..., description="Flat price per period (CHF)") + maxUsers: Optional[int] = Field(None, description="Max users (None = unlimited)") + maxFeatureInstances: Optional[int] = Field(None, description="Max feature instances (None = unlimited)") + maxDataVolumeMB: Optional[int] = Field(None, description="Max storage in MB (None = unlimited)") + budgetAiCHF: Optional[float] = Field(None, description="Fixed AI budget per period (CHF)") + note: Optional[str] = Field(None, description="Free-text note (e.g. contract reference)") + + +class EnterpriseRenewRequest(BaseModel): + subscriptionId: str = Field(..., description="ID of the enterprise subscription to renew") + newEndDate: float = Field(..., description="New period end (UTC unix timestamp)") + autoRenew: Optional[bool] = Field(None, description="Override auto-renew flag") + flatPriceCHF: Optional[float] = Field(None, description="Override flat price (CHF)") + maxUsers: Optional[int] = Field(None, description="Override max users") + maxFeatureInstances: Optional[int] = Field(None, description="Override max feature instances") + maxDataVolumeMB: Optional[int] = Field(None, description="Override max storage (MB)") + budgetAiCHF: Optional[float] = Field(None, description="Override AI budget (CHF)") + note: Optional[str] = Field(None, description="Override note") + + +class EnterpriseUpdateRequest(BaseModel): + subscriptionId: str = Field(..., description="ID of the enterprise subscription to update") + enterpriseFlatPriceCHF: Optional[float] = Field(None, description="New flat price (CHF)") + enterpriseMaxUsers: Optional[int] = Field(None, description="New max users") + enterpriseMaxFeatureInstances: Optional[int] = Field(None, description="New max feature instances") + enterpriseMaxDataVolumeMB: Optional[int] = Field(None, description="New max storage (MB)") + enterpriseBudgetAiCHF: Optional[float] = Field(None, description="New AI budget (CHF)") + enterpriseNote: Optional[str] = Field(None, description="New note") + recurring: Optional[bool] = Field(None, description="Update auto-renew flag") + + +@router.post("/enterprise/create", response_model=Dict[str, Any]) +@limiter.limit("10/minute") +def createEnterprise( + request: Request, + data: EnterpriseCreateRequest, + context: RequestContext = Depends(getRequestContext), +): + """SysAdmin: create an enterprise subscription with custom flat pricing and limits.""" + if not context.isPlatformAdmin: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Sysadmin role required")) + + from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import ( + getService as getSubscriptionService, + ) + try: + subService = getSubscriptionService(context.user, data.mandateId) + return subService.createEnterprise( + mandateId=data.mandateId, + startDate=data.startDate, + endDate=data.endDate, + autoRenew=data.autoRenew, + flatPriceCHF=data.flatPriceCHF, + maxUsers=data.maxUsers, + maxFeatureInstances=data.maxFeatureInstances, + maxDataVolumeMB=data.maxDataVolumeMB, + budgetAiCHF=data.budgetAiCHF, + note=data.note, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error("Error creating enterprise subscription: %s", e) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/enterprise/renew", response_model=Dict[str, Any]) +@limiter.limit("10/minute") +def renewEnterprise( + request: Request, + data: EnterpriseRenewRequest, + context: RequestContext = Depends(getRequestContext), +): + """SysAdmin: renew an enterprise subscription (expire old, create new with same or overridden params).""" + if not context.isPlatformAdmin: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Sysadmin role required")) + + from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import ( + getService as getSubscriptionService, + ) + from modules.interfaces.interfaceDbSubscription import getRootInterface as getSubRootInterface + sub = getSubRootInterface().getById(data.subscriptionId) + if not sub: + raise HTTPException(status_code=404, detail=routeApiMsg("Subscription not found")) + mandateId = sub["mandateId"] + + overrides = {} + for field in ("autoRenew", "flatPriceCHF", "maxUsers", "maxFeatureInstances", + "maxDataVolumeMB", "budgetAiCHF", "note"): + val = getattr(data, field, None) + if val is not None: + overrides[field] = val + + try: + subService = getSubscriptionService(context.user, mandateId) + return subService.renewEnterprise(data.subscriptionId, data.newEndDate, overrides or None) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error("Error renewing enterprise subscription %s: %s", data.subscriptionId, e) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.put("/enterprise/update", response_model=Dict[str, Any]) +@limiter.limit("30/minute") +def updateEnterprise( + request: Request, + data: EnterpriseUpdateRequest, + context: RequestContext = Depends(getRequestContext), +): + """SysAdmin: update enterprise subscription parameters (limits, price, note).""" + if not context.isPlatformAdmin: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Sysadmin role required")) + + from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import ( + getService as getSubscriptionService, + ) + from modules.interfaces.interfaceDbSubscription import getRootInterface as getSubRootInterface + sub = getSubRootInterface().getById(data.subscriptionId) + if not sub: + raise HTTPException(status_code=404, detail=routeApiMsg("Subscription not found")) + mandateId = sub["mandateId"] + + changes = {k: v for k, v in data.model_dump(exclude={"subscriptionId"}).items() if v is not None} + + try: + subService = getSubscriptionService(context.user, mandateId) + return subService.updateEnterprise(data.subscriptionId, changes) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error("Error updating enterprise subscription %s: %s", data.subscriptionId, e) + raise HTTPException(status_code=500, detail=str(e)) diff --git a/modules/serviceCenter/services/serviceAgent/agentLoop.py b/modules/serviceCenter/services/serviceAgent/agentLoop.py index e1244c89..b51ffb85 100644 --- a/modules/serviceCenter/services/serviceAgent/agentLoop.py +++ b/modules/serviceCenter/services/serviceAgent/agentLoop.py @@ -32,6 +32,44 @@ from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription logger = logging.getLogger(__name__) +# --------------------------------------------------------------------------- +# RAG session cache -- avoids repeated embedding + vector search per turn +# --------------------------------------------------------------------------- + +_RAG_CACHE_TTL_S = 120.0 +_RAG_CACHE_MAX_MSGS = 5 +_RAG_CACHE_MAX_ENTRIES = 200 + +_ragCache: Dict[str, Dict[str, Any]] = {} + + +async def _getOrRefreshRag( + workflowId: str, + buildRagContextFn, + *, + forceRefresh: bool = False, + **ragKwargs, +) -> str: + """Return cached RAG context or compute fresh. Thread-safe via GIL for dict ops.""" + now = time.time() + cached = _ragCache.get(workflowId) + if cached and not forceRefresh: + age = now - cached["ts"] + if age < _RAG_CACHE_TTL_S and cached["msgs"] < _RAG_CACHE_MAX_MSGS: + cached["msgs"] += 1 + return cached["ctx"] + + ragKwargs["workflowId"] = workflowId + ctx = await buildRagContextFn(**ragKwargs) + + if len(_ragCache) >= _RAG_CACHE_MAX_ENTRIES: + oldest = min(_ragCache, key=lambda k: _ragCache[k]["ts"]) + _ragCache.pop(oldest, None) + + _ragCache[workflowId] = {"ctx": ctx or "", "ts": now, "msgs": 0} + return ctx or "" + + async def runAgentLoop( prompt: str, toolRegistry: ToolRegistry, @@ -75,15 +113,20 @@ async def runAgentLoop( featureInstanceId=featureInstanceId ) - activeToolSet = config.toolSet if config else None - tools = toolRegistry.getTools(toolSet=activeToolSet) - toolDefinitions = toolRegistry.formatToolsForFunctionCalling(toolSet=activeToolSet) + if config and config.excludeAllTools: + tools = [] + toolDefinitions = None + toolsText = "" + else: + activeToolSet = config.toolSet if config else None + tools = toolRegistry.getTools(toolSet=activeToolSet) + toolDefinitions = toolRegistry.formatToolsForFunctionCalling(toolSet=activeToolSet) - # Text-based tool descriptions are ONLY used as fallback when native function - # calling is unavailable. Including both creates conflicting instructions - # (text ```tool_call format vs native tool_use blocks) and can cause the model - # to respond with plain text instead of actual tool calls. - toolsText = "" if toolDefinitions else toolRegistry.formatToolsForPrompt(toolSet=activeToolSet) + # Text-based tool descriptions are ONLY used as fallback when native function + # calling is unavailable. Including both creates conflicting instructions + # (text ```tool_call format vs native tool_use blocks) and can cause the model + # to respond with plain text instead of actual tool calls. + toolsText = "" if toolDefinitions else toolRegistry.formatToolsForPrompt(toolSet=activeToolSet) if systemPromptOverride: systemPrompt = systemPromptOverride @@ -100,7 +143,7 @@ async def runAgentLoop( roundStartTime = time.time() roundLog = AgentRoundLog(roundNumber=state.currentRound) - # RAG context injection (before each round for fresh relevance) + # RAG context injection (cached for conversational turns, fresh for tool turns) if buildRagContextFn: try: latestUserMsg = "" @@ -108,9 +151,12 @@ async def runAgentLoop( if msg.get("role") == "user": latestUserMsg = msg.get("content", "") break - ragContext = await buildRagContextFn( + isConversational = config and config.excludeAllTools + ragContext = await _getOrRefreshRag( + workflowId, + buildRagContextFn, + forceRefresh=not isConversational, currentPrompt=latestUserMsg or prompt, - workflowId=workflowId, userId=userId, featureInstanceId=featureInstanceId, mandateId=mandateId, @@ -166,12 +212,15 @@ async def runAgentLoop( ) # AI call + aiOptions = AiCallOptions( + operationType=config.operationType or OperationTypeEnum.AGENT, + temperature=config.temperature, + ) + if config.priority: + aiOptions.priority = config.priority aiRequest = AiCallRequest( prompt="", - options=AiCallOptions( - operationType=config.operationType or OperationTypeEnum.AGENT, - temperature=config.temperature - ), + options=aiOptions, messages=conversation.messages, tools=toolDefinitions if toolDefinitions else None, ) diff --git a/modules/serviceCenter/services/serviceAgent/datamodelAgent.py b/modules/serviceCenter/services/serviceAgent/datamodelAgent.py index 16cb1964..9428af49 100644 --- a/modules/serviceCenter/services/serviceAgent/datamodelAgent.py +++ b/modules/serviceCenter/services/serviceAgent/datamodelAgent.py @@ -6,7 +6,7 @@ from typing import List, Dict, Any, Optional from enum import Enum from pydantic import BaseModel, Field from modules.shared.timeUtils import getUtcTimestamp -from modules.datamodels.datamodelAi import OperationTypeEnum +from modules.datamodels.datamodelAi import OperationTypeEnum, PriorityEnum import uuid @@ -101,6 +101,18 @@ class AgentConfig(BaseModel): "manipulate the workflow graph, not execute its actions." ), ) + excludeAllTools: bool = Field( + default=False, + description=( + "If True, send no tool definitions to the LLM at all. " + "Used for pure conversational turns (e.g. CommCoach coaching chat) " + "where tools are not needed and would only add latency." + ), + ) + priority: Optional[PriorityEnum] = Field( + default=None, + description="Model selection priority: speed | quality | cost | balanced. None = use default (balanced).", + ) class AgentState(BaseModel): diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/documentRendererBaseTemplate.py b/modules/serviceCenter/services/serviceGeneration/renderers/documentRendererBaseTemplate.py index d7c237fa..52daae29 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/documentRendererBaseTemplate.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/documentRendererBaseTemplate.py @@ -118,12 +118,28 @@ class BaseRenderer(ABC): para = style["paragraph"] lst = style["list"] cb = style["codeBlock"] + colors = style.get("colors") if isinstance(style.get("colors"), dict) else {} + primaryColor = colors.get("primary", "#1F3864") + rawDocTitle = style.get("documentTitle") + docTitle = rawDocTitle if isinstance(rawDocTitle, dict) else {} + titleSizePt = docTitle.get("sizePt") + if titleSizePt is None: + titleSizePt = max(int(h1["sizePt"]) + 4, 26) + titleColor = docTitle.get("color", primaryColor) + titleBold = docTitle.get("weight", "bold") == "bold" + titleAlign = docTitle.get("align", "center") + if titleAlign not in ("left", "center", "right"): + titleAlign = "center" + titleSpaceBefore = docTitle.get("spaceBeforePt", 0) + titleSpaceAfter = docTitle.get("spaceAfterPt", 18) return { "title": { - "font_size": h1["sizePt"], "color": h1["color"], - "bold": h1.get("weight") == "bold", "align": "left", - "space_before": 0, - "space_after": h1.get("spaceAfterPt", 8), + "font_size": titleSizePt, + "color": titleColor, + "bold": titleBold, + "align": titleAlign, + "space_before": titleSpaceBefore, + "space_after": titleSpaceAfter, }, "heading1": { "font_size": h1["sizePt"], "color": h1["color"], diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererHtml.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererHtml.py index b39efd50..33093b8e 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererHtml.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererHtml.py @@ -146,8 +146,8 @@ class RendererHtml(BaseRenderer): htmlParts.append('') htmlParts.append('') - # Document header - htmlParts.append(f'

{documentTitle}

') + # Document header (not an h1 — body headings keep a single outline level scale) + htmlParts.append(f'

{documentTitle}

') # Main content htmlParts.append('
') @@ -412,16 +412,27 @@ class RendererHtml(BaseRenderer): css_parts.append(" margin: 0; padding: 20px;") css_parts.append("}") - # Document title (uses h1 style) - h1 = headings.get("h1", {}) + docTitle = style.get("documentTitle") if isinstance(style.get("documentTitle"), dict) else {} + dtSize = docTitle.get("sizePt") + if dtSize is None: + dtSize = max(headings.get("h1", {}).get("sizePt", 22) + 4, 26) + dtColor = docTitle.get("color", primaryColor) + dtWeight = docTitle.get("weight", "bold") + dtAlign = docTitle.get("align", "center") + if dtAlign not in ("left", "center", "right"): + dtAlign = "center" + dtSpaceAfter = docTitle.get("spaceAfterPt", 18) css_parts.append(".document-title {") - css_parts.append(f" font-size: {h1.get('sizePt', 24)}pt;") - css_parts.append(f" color: {h1.get('color', primaryColor)};") - css_parts.append(f" font-weight: {h1.get('weight', 'bold')};") - css_parts.append(" margin: 0 0 1em 0;") + css_parts.append(f" font-size: {dtSize}pt;") + css_parts.append(f" color: {dtColor};") + css_parts.append(f" font-weight: {dtWeight};") + css_parts.append(f" text-align: {dtAlign};") + css_parts.append(" margin: 0;") + css_parts.append(f" margin-bottom: {dtSpaceAfter}pt;") css_parts.append("}") # Headings h1-h4 + h1 = headings.get("h1", {}) for level in range(1, 5): key = f"h{level}" h = headings.get(key, h1 if level == 1 else headings.get(f"h{level-1}", {})) diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererMarkdown.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererMarkdown.py index 1113f1a2..84649ae7 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererMarkdown.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererMarkdown.py @@ -289,7 +289,8 @@ class RendererMarkdown(BaseRenderer): if text: level = max(1, min(6, level)) - return f"{'#' * level} {text}" + md_level = min(6, level + 1) + return f"{'#' * md_level} {text}" return "" diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py index 8ba20c6a..f75a5108 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py @@ -192,6 +192,7 @@ class RendererPdf(BaseRenderer): # Extract sections and metadata from standardized schema sections = self._extractSections(json_content) + metadata = self._extractMetadata(json_content) # Create a buffer to hold the PDF buffer = io.BytesIO() @@ -204,8 +205,13 @@ class RendererPdf(BaseRenderer): else: doc = SimpleDocTemplate(buffer, pagesize=A4, rightMargin=72, leftMargin=72, topMargin=72, bottomMargin=18) - # Build PDF content (no cover page — body starts on page 1; filename still uses `title`) + # Body starts on page 1 — optional document title uses styles["title"] (distinct from H1) story = [] + document_title = (title or "").strip() + if not document_title and isinstance(metadata, dict): + document_title = (metadata.get("title") or "").strip() + if document_title: + story.append(self._paragraphFromInlineMarkdown(document_title, self._createDocumentTitleStyle(styles))) # Process each section (sections already extracted above) self.services.utils.debugLogToFile(f"PDF SECTIONS TO PROCESS: {len(sections)} sections", "PDF_RENDERER") @@ -561,6 +567,22 @@ class RendererPdf(BaseRenderer): "space_before": sb, } + def _createDocumentTitleStyle(self, styles: Dict[str, Any]) -> ParagraphStyle: + """Paragraph style for the document title (metadata/doc title — not heading level 1).""" + title_style_def = styles.get("title") or {} + fs = title_style_def.get("font_size", 26) + bold = title_style_def.get("bold", True) + return ParagraphStyle( + "DocumentTitle", + fontName="Helvetica-Bold" if bold else "Helvetica", + fontSize=fs, + spaceAfter=title_style_def.get("space_after", 18), + spaceBefore=title_style_def.get("space_before", 0), + alignment=self._getAlignment(title_style_def.get("align", "center")), + textColor=self._hexToColor(title_style_def.get("color", "#1F3864")), + leading=fs * 1.25, + ) + def _createHeadingStyle(self, styles: Dict[str, Any], level: int) -> ParagraphStyle: """Create heading style from style definitions.""" heading_key = f"heading{level}" diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererText.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererText.py index 67eab4e8..1af2aec5 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererText.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererText.py @@ -340,11 +340,8 @@ class RendererText(BaseRenderer): except (TypeError, ValueError): level_i = 1 level_i = max(1, min(6, level_i)) - if level_i == 1: - return f"{text}\n{'=' * len(text)}" - if level_i == 2: - return f"{text}\n{'-' * len(text)}" - return f"{'#' * level_i} {text}" + md_level = min(6, level_i + 1) + return f"{'#' * md_level} {text}" except Exception as e: self.logger.warning(f"Error rendering heading: {str(e)}") diff --git a/modules/serviceCenter/services/serviceGeneration/styleDefaults.py b/modules/serviceCenter/services/serviceGeneration/styleDefaults.py index 1984f18d..6d890f29 100644 --- a/modules/serviceCenter/services/serviceGeneration/styleDefaults.py +++ b/modules/serviceCenter/services/serviceGeneration/styleDefaults.py @@ -16,8 +16,16 @@ DEFAULT_STYLE: Dict[str, Any] = { "accent": "#2980B9", "background": "#FFFFFF", }, + "documentTitle": { + "sizePt": 28, + "weight": "bold", + "color": "#1F3864", + "spaceBeforePt": 0, + "spaceAfterPt": 18, + "align": "center", + }, "headings": { - "h1": {"sizePt": 24, "weight": "bold", "color": "#1F3864", "spaceBeforePt": 24, "spaceAfterPt": 8}, + "h1": {"sizePt": 22, "weight": "bold", "color": "#1F3864", "spaceBeforePt": 22, "spaceAfterPt": 8}, "h2": {"sizePt": 18, "weight": "bold", "color": "#1F3864", "spaceBeforePt": 20, "spaceAfterPt": 6}, "h3": {"sizePt": 14, "weight": "bold", "color": "#2C3E50", "spaceBeforePt": 16, "spaceAfterPt": 4}, "h4": {"sizePt": 12, "weight": "bold", "color": "#2C3E50", "spaceBeforePt": 12, "spaceAfterPt": 3}, diff --git a/modules/serviceCenter/services/serviceSubscription/enterpriseRenewalScheduler.py b/modules/serviceCenter/services/serviceSubscription/enterpriseRenewalScheduler.py new file mode 100644 index 00000000..9db20b0f --- /dev/null +++ b/modules/serviceCenter/services/serviceSubscription/enterpriseRenewalScheduler.py @@ -0,0 +1,89 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +"""Enterprise subscription auto-renewal scheduler. + +Runs daily via eventManager (APScheduler). Checks all enterprise subscriptions +with autoRenew=True whose period has ended and renews them automatically +(old -> EXPIRED, new -> ACTIVE with same duration and params, budget credit, +invoice email). +""" + +import logging +from datetime import datetime, timezone + +logger = logging.getLogger(__name__) + + +async def _runEnterpriseAutoRenewal() -> None: + """Scheduled task: auto-renew enterprise subscriptions whose period has ended.""" + try: + from modules.interfaces.interfaceDbSubscription import getRootInterface as _getSubRoot + from modules.datamodels.datamodelSubscription import SubscriptionStatusEnum + + subIface = _getSubRoot() + allSubs = subIface.listAll([SubscriptionStatusEnum.ACTIVE]) + + nowTs = datetime.now(timezone.utc).timestamp() + renewed = 0 + + for sub in allSubs: + if not sub.get("isEnterprise"): + continue + if not sub.get("recurring"): + continue + periodEnd = sub.get("currentPeriodEnd") + if not periodEnd or periodEnd > nowTs: + continue + + mandateId = sub["mandateId"] + subId = sub["id"] + periodStart = sub.get("currentPeriodStart") or sub.get("startedAt") or nowTs + periodDuration = periodEnd - periodStart + if periodDuration <= 0: + periodDuration = 30 * 86400 + newEndDate = nowTs + periodDuration + + try: + from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import ( + getService as getSubscriptionService, + ) + from modules.security.rootAccess import getRootUser + rootUser = getRootUser() + subService = getSubscriptionService(rootUser, mandateId) + subService.renewEnterprise(subId, newEndDate) + renewed += 1 + logger.info( + "Auto-renewed enterprise subscription %s for mandate %s (new end: %s)", + subId, mandateId, + datetime.fromtimestamp(newEndDate, tz=timezone.utc).isoformat(), + ) + except Exception as e: + logger.error( + "Auto-renewal failed for enterprise subscription %s mandate %s: %s", + subId, mandateId, e, + ) + + if renewed: + logger.info("Enterprise auto-renewal completed: %d subscription(s) renewed", renewed) + + except Exception as e: + logger.error("Enterprise auto-renewal task failed: %s", e) + + +def registerEnterpriseRenewalScheduler() -> None: + """Register the enterprise auto-renewal cron job (daily at 06:00 UTC).""" + try: + from modules.shared.eventManagement import eventManager + + eventManager.registerCron( + jobId="enterprise_auto_renewal", + func=_runEnterpriseAutoRenewal, + cronKwargs={ + "hour": "6", + "minute": "0", + }, + ) + logger.info("Enterprise auto-renewal scheduler registered (daily at 06:00 UTC)") + + except Exception as e: + logger.error("Failed to register enterprise auto-renewal scheduler: %s", e) diff --git a/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py b/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py index 1a902945..0d3ae954 100644 --- a/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py +++ b/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py @@ -26,6 +26,7 @@ from modules.interfaces.interfaceDbSubscription import ( getInterface as getSubscriptionInterface, InvalidTransitionError, ) +from modules.shared.i18nRegistry import t logger = logging.getLogger(__name__) @@ -581,6 +582,256 @@ class SubscriptionService: def syncStripeQuantity(self, subscriptionId: str): self._interface.syncQuantityToStripe(subscriptionId) + # ========================================================================= + # Enterprise subscription management (sysadmin-only) + # ========================================================================= + + def createEnterprise( + self, mandateId: str, + startDate: float, endDate: float, autoRenew: bool, + flatPriceCHF: float, + maxUsers: Optional[int], maxFeatureInstances: Optional[int], + maxDataVolumeMB: Optional[int], budgetAiCHF: Optional[float], + note: Optional[str] = None, + ) -> Dict[str, Any]: + """Create a new enterprise subscription with custom flat pricing and limits. + + 1. Cleanup PENDING/SCHEDULED predecessors + 2. Expire current operative subscription (no Stripe cancel) + 3. Create ACTIVE MandateSubscription with enterprise fields + 4. Credit fixed AI budget to mandate pool + 5. Send invoice email to mandate admins + """ + self._cleanupPreparatorySubscriptions(mandateId) + + currentOperative = self._interface.getOperativeForMandate(mandateId) + if currentOperative: + self._expireOperative(currentOperative["id"], mandateId) + + sub = MandateSubscription( + mandateId=mandateId, + planKey="ENTERPRISE", + status=SubscriptionStatusEnum.ACTIVE, + recurring=autoRenew, + startedAt=datetime.now(timezone.utc).timestamp(), + currentPeriodStart=startDate, + currentPeriodEnd=endDate, + isEnterprise=True, + enterpriseFlatPriceCHF=flatPriceCHF, + enterpriseMaxUsers=maxUsers, + enterpriseMaxFeatureInstances=maxFeatureInstances, + enterpriseMaxDataVolumeMB=maxDataVolumeMB, + enterpriseBudgetAiCHF=budgetAiCHF, + enterpriseNote=note, + ) + + created = self._interface.createSubscription(sub) + self.invalidateCache(mandateId) + + self._creditEnterpriseBudget(mandateId, budgetAiCHF, "Erstaktivierung") + _notifyEnterpriseInvoice(mandateId, created) + + logger.info("Enterprise subscription created for mandate %s: id=%s", mandateId, created["id"]) + return created + + def renewEnterprise( + self, subscriptionId: str, newEndDate: float, + overrides: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """Renew an enterprise subscription: expire old, create new with same or overridden params. + + 1. Load + validate old subscription + 2. Expire old subscription + 3. Create new ACTIVE subscription (clone params, apply overrides) + 4. Credit AI budget + 5. Send invoice email + """ + oldSub = self._interface.getById(subscriptionId) + if not oldSub: + raise ValueError(f"Subscription {subscriptionId} not found") + if not oldSub.get("isEnterprise"): + raise ValueError(f"Subscription {subscriptionId} is not an enterprise subscription") + + mandateId = oldSub["mandateId"] + self._interface.forceExpire(subscriptionId) + self.invalidateCache(mandateId) + + overrides = overrides or {} + nowTs = datetime.now(timezone.utc).timestamp() + startDate = nowTs + autoRenew = overrides.get("autoRenew", oldSub.get("recurring", False)) + flatPriceCHF = overrides.get("flatPriceCHF", oldSub.get("enterpriseFlatPriceCHF")) + maxUsers = overrides.get("maxUsers", oldSub.get("enterpriseMaxUsers")) + maxFeatureInstances = overrides.get("maxFeatureInstances", oldSub.get("enterpriseMaxFeatureInstances")) + maxDataVolumeMB = overrides.get("maxDataVolumeMB", oldSub.get("enterpriseMaxDataVolumeMB")) + budgetAiCHF = overrides.get("budgetAiCHF", oldSub.get("enterpriseBudgetAiCHF")) + note = overrides.get("note", oldSub.get("enterpriseNote")) + + sub = MandateSubscription( + mandateId=mandateId, + planKey="ENTERPRISE", + status=SubscriptionStatusEnum.ACTIVE, + recurring=autoRenew, + startedAt=nowTs, + currentPeriodStart=startDate, + currentPeriodEnd=newEndDate, + isEnterprise=True, + enterpriseFlatPriceCHF=flatPriceCHF, + enterpriseMaxUsers=maxUsers, + enterpriseMaxFeatureInstances=maxFeatureInstances, + enterpriseMaxDataVolumeMB=maxDataVolumeMB, + enterpriseBudgetAiCHF=budgetAiCHF, + enterpriseNote=note, + ) + + created = self._interface.createSubscription(sub) + self.invalidateCache(mandateId) + + self._creditEnterpriseBudget(mandateId, budgetAiCHF, "Erneuerung") + _notifyEnterpriseInvoice(mandateId, created) + + logger.info( + "Enterprise subscription renewed for mandate %s: old=%s -> new=%s", + mandateId, subscriptionId, created["id"], + ) + return created + + def updateEnterprise(self, subscriptionId: str, changes: Dict[str, Any]) -> Dict[str, Any]: + """Update enterprise subscription parameters (limits, note, flat price). + + Only enterprise-specific fields are allowed. No status change.""" + sub = self._interface.getById(subscriptionId) + if not sub: + raise ValueError(f"Subscription {subscriptionId} not found") + if not sub.get("isEnterprise"): + raise ValueError(f"Subscription {subscriptionId} is not an enterprise subscription") + + allowedFields = { + "enterpriseFlatPriceCHF", "enterpriseMaxUsers", "enterpriseMaxFeatureInstances", + "enterpriseMaxDataVolumeMB", "enterpriseBudgetAiCHF", "enterpriseNote", + "recurring", + } + updateData = {k: v for k, v in changes.items() if k in allowedFields} + if not updateData: + raise ValueError("No valid enterprise fields to update") + + result = self._interface.updateFields(subscriptionId, updateData) + self.invalidateCache(sub["mandateId"]) + logger.info("Enterprise subscription %s updated: %s", subscriptionId, list(updateData.keys())) + return result + + def _creditEnterpriseBudget( + self, mandateId: str, budgetAiCHF: Optional[float], periodLabel: str, + ) -> None: + if not budgetAiCHF or budgetAiCHF <= 0: + return + try: + from modules.interfaces.interfaceDbBilling import getRootInterface as _getBillingRoot + _getBillingRoot().creditSubscriptionBudget( + mandateId, "ENTERPRISE", periodLabel=periodLabel, + enterpriseBudgetOverride=budgetAiCHF, + ) + except Exception as e: + logger.error("Enterprise budget credit failed for mandate %s: %s", mandateId, e) + + +# ============================================================================ +# Enterprise Invoice Email +# ============================================================================ + +def _notifyEnterpriseInvoice(mandateId: str, subRecord: Dict[str, Any]) -> None: + """Send enterprise invoice email to mandate admins.""" + try: + from modules.shared.notifyMandateAdmins import notifyMandateAdmins + + rawHtml = _buildEnterpriseInvoiceHtml(subRecord) + flatPrice = subRecord.get("enterpriseFlatPriceCHF") or 0 + notifyMandateAdmins( + mandateId, + t("[PowerOn] Enterprise-Abonnement — Rechnung") + f" (CHF {flatPrice:,.2f})", + t("Enterprise-Abonnement — Rechnung"), + [ + t("Das Enterprise-Abonnement wurde aktiviert."), + t("Bitte begleichen Sie den Rechnungsbetrag innert 10 Tagen."), + t("Details zum Abonnement finden Sie unter Billing-Verwaltung."), + ], + rawHtmlBlock=rawHtml, + ) + except Exception as e: + logger.error("Enterprise invoice email failed for mandate %s: %s", mandateId, e) + + +def _buildEnterpriseInvoiceHtml(subRecord: Dict[str, Any]) -> str: + """Build HTML invoice summary for enterprise subscription email.""" + flatPrice = subRecord.get("enterpriseFlatPriceCHF") or 0 + maxUsers = subRecord.get("enterpriseMaxUsers") + maxFeatures = subRecord.get("enterpriseMaxFeatureInstances") + maxStorageMB = subRecord.get("enterpriseMaxDataVolumeMB") + budgetAi = subRecord.get("enterpriseBudgetAiCHF") + note = subRecord.get("enterpriseNote") or "" + periodStart = subRecord.get("currentPeriodStart") + periodEnd = subRecord.get("currentPeriodEnd") + + def _chf(amount: float) -> str: + return f"CHF {amount:,.2f}".replace(",", "'") + + def _fmtDate(ts: Optional[float]) -> str: + if not ts: + return "—" + from datetime import datetime, timezone + return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%d.%m.%Y") + + detailRows = "" + if maxUsers is not None: + detailRows += ( + f'{t("Benutzer")}' + f'max. {maxUsers}' + ) + if maxFeatures is not None: + detailRows += ( + f'{t("Module")}' + f'max. {maxFeatures}' + ) + if maxStorageMB is not None: + storageLabel = f"{maxStorageMB} MB" if maxStorageMB < 1024 else f"{maxStorageMB / 1024:.1f} GB" + detailRows += ( + f'{t("Datenvolumen")}' + f'max. {storageLabel}' + ) + if budgetAi is not None and budgetAi > 0: + detailRows += ( + f'{t("KI-Budget")}' + f'{_chf(budgetAi)}' + ) + + noteHtml = "" + if note: + import html as htmlmod + noteHtml = ( + f'

' + f'{t("Notiz")}: {htmlmod.escape(note)}

' + ) + + return ( + f'' + f'' + f'' + f'' + f'' + f'' + f'{detailRows}' + f'' + f'' + f'' + f'' + f'' + f'
{t("Zeitraum")}{_fmtDate(periodStart)} – {_fmtDate(periodEnd)}
{t("Pauschale")}' + f'{_chf(flatPrice)}
' + f'

' + f'{t("Zahlungsfrist")}: {t("10 Tage")}

' + f'{noteHtml}' + ) + # ============================================================================ # Notifications @@ -608,66 +859,66 @@ def _notifySubscriptionChange( templates: Dict[str, Dict[str, Any]] = { "activated": { - "subject": f"[PowerOn] Abonnement aktiviert — {planLabel}", - "headline": "Abonnement aktiviert", + "subject": f"[PowerOn] {t('Abonnement aktiviert')} — {planLabel}", + "headline": t("Abonnement aktiviert"), "paragraphs": [ p for p in [ - f"Das Abonnement wurde auf den Plan «{planLabel}» aktiviert.", + t("Das Abonnement wurde auf den Plan «{planLabel}» aktiviert.").format(planLabel=planLabel), platformHint, - "Sie können Ihr Abonnement jederzeit unter Billing-Verwaltung › Abonnement einsehen und verwalten.", + t("Sie können Ihr Abonnement jederzeit unter Billing-Verwaltung › Abonnement einsehen und verwalten."), ] if p ], }, "cancelled": { - "subject": f"[PowerOn] Abonnement gekündigt — {planLabel}", - "headline": "Abonnement gekündigt", + "subject": f"[PowerOn] {t('Abonnement gekündigt')} — {planLabel}", + "headline": t("Abonnement gekündigt"), "paragraphs": [ p for p in [ - f"Das Abonnement «{planLabel}» wurde gekündigt.", + t("Das Abonnement «{planLabel}» wurde gekündigt.").format(planLabel=planLabel), platformHint, - "Die Kündigung wird zum Ende der aktuellen bezahlten Periode wirksam. Bis dahin bleibt der volle Zugang bestehen.", + t("Die Kündigung wird zum Ende der aktuellen bezahlten Periode wirksam. Bis dahin bleibt der volle Zugang bestehen."), ] if p ], }, "force_cancelled": { - "subject": f"[PowerOn] Abonnement sofort beendet — {planLabel}", - "headline": "Abonnement sofort beendet", + "subject": f"[PowerOn] {t('Abonnement sofort beendet')} — {planLabel}", + "headline": t("Abonnement sofort beendet"), "paragraphs": [ p for p in [ - f"Das Abonnement «{planLabel}» wurde durch den Plattform-Administrator sofort beendet.", + t("Das Abonnement «{planLabel}» wurde durch den Plattform-Administrator sofort beendet.").format(planLabel=planLabel), platformHint, - "Der Zugang wurde per sofort deaktiviert. Bei Fragen wenden Sie sich an den Plattform-Support.", + t("Der Zugang wurde per sofort deaktiviert. Bei Fragen wenden Sie sich an den Plattform-Support."), ] if p ], }, "trial_expired": { - "subject": "[PowerOn] Testphase abgelaufen", - "headline": "Testphase abgelaufen", + "subject": f"[PowerOn] {t('Testphase abgelaufen')}", + "headline": t("Testphase abgelaufen"), "paragraphs": [ p for p in [ - "Die kostenlose Testphase ist abgelaufen.", + t("Die kostenlose Testphase ist abgelaufen."), platformHint, - "Bitte wählen Sie einen Plan unter Billing-Verwaltung › Abonnement, damit der Zugang nicht unterbrochen wird.", + t("Bitte wählen Sie einen Plan unter Billing-Verwaltung › Abonnement, damit der Zugang nicht unterbrochen wird."), ] if p ], }, "payment_failed": { - "subject": f"[PowerOn] Zahlung fehlgeschlagen — {planLabel}", - "headline": "Zahlung fehlgeschlagen", + "subject": f"[PowerOn] {t('Zahlung fehlgeschlagen')} — {planLabel}", + "headline": t("Zahlung fehlgeschlagen"), "paragraphs": [ p for p in [ - f"Die Zahlung für das Abonnement «{planLabel}» ist fehlgeschlagen.", + t("Die Zahlung für das Abonnement «{planLabel}» ist fehlgeschlagen.").format(planLabel=planLabel), platformHint, - "Bitte aktualisieren Sie Ihr Zahlungsmittel unter Billing-Verwaltung.", + t("Bitte aktualisieren Sie Ihr Zahlungsmittel unter Billing-Verwaltung."), ] if p ], }, } tpl = templates.get(event, { - "subject": f"[PowerOn] Abonnement-Änderung — {planLabel}", - "headline": "Abonnement-Änderung", - "paragraphs": [f"Änderung am Abonnement «{planLabel}»."], + "subject": f"[PowerOn] {t('Abonnement-Änderung')} — {planLabel}", + "headline": t("Abonnement-Änderung"), + "paragraphs": [t("Änderung am Abonnement «{planLabel}».").format(planLabel=planLabel)], }) notifyMandateAdmins( @@ -699,7 +950,7 @@ def _buildInvoiceSummaryHtml( instanceTotal = billableModules * instancePrice netTotal = userTotal + instanceTotal - periodLabel = {"MONTHLY": "Monatlich", "YEARLY": "Jährlich"}.get(plan.billingPeriod, plan.billingPeriod) + periodLabel = {"MONTHLY": t("Monatlich"), "YEARLY": t("Jährlich")}.get(plan.billingPeriod, plan.billingPeriod) def _chf(amount: float) -> str: return f"CHF {amount:,.2f}".replace(",", "'") @@ -707,13 +958,13 @@ def _buildInvoiceSummaryHtml( rows = "" if userPrice > 0: rows += ( - f'Benutzer-Lizenzen' + f'{t("Benutzer-Lizenzen")}' f'{userCount} × {_chf(userPrice)}' f'{_chf(userTotal)}\n' ) if instancePrice > 0 and billableModules > 0: rows += ( - f'Module ({instanceCount} total, {plan.includedModules} inkl.)' + f'{t("Module")} ({instanceCount} total, {plan.includedModules} {t("inkl.")})' f'{billableModules} × {_chf(instancePrice)}' f'{_chf(instanceTotal)}\n' ) @@ -733,7 +984,7 @@ def _buildInvoiceSummaryHtml( invoiceLink = ( f'

' f'' - f'Vollständige Rechnung mit MwSt-Ausweis anzeigen

\n' + f'{t("Vollständige Rechnung mit MwSt-Ausweis anzeigen")}

\n' ) except Exception as e: logger.warning("Could not fetch Stripe invoice URL for sub %s: %s", stripeSubId, e) @@ -741,13 +992,13 @@ def _buildInvoiceSummaryHtml( return ( f'' f'' - f'' - f'' - f'' + f'' + f'' + f'' f'' f'{rows}' f'' - f'' + f'' f'' f'' f'' @@ -776,7 +1027,7 @@ def _buildCancelSummaryHtml(subRecord: Dict[str, Any], platformUrl: str = "") -> parts.append( f'

' f'' - f'Letzte Stripe-Rechnung anzeigen

' + f'{t("Letzte Stripe-Rechnung anzeigen")}

' ) except Exception as e: logger.warning("Could not fetch Stripe invoice URL for sub %s: %s", stripeSubId, e) @@ -822,7 +1073,7 @@ class SubscriptionInactiveException(Exception): self.mandateId = mandateId self.reason = _subscriptionReasonForStatus(status) self.userAction = _subscriptionUserActionForStatus(status) - self.message = message or ( + self.message = message or t( "Kein aktives Abonnement für diesen Mandanten. Bitte wählen Sie einen Plan unter Billing." ) super().__init__(self.message) @@ -837,47 +1088,62 @@ class SubscriptionInactiveException(Exception): return out -_SUBSCRIPTION_LIMITS_UI_HINT_DE = ( - " Details zu Ihrem Abonnement, den enthaltenen Limits und Upgrade-Optionen: " - "Menü «Administration» → «Billing» → Registerkarte «Abonnement»." -) +SUBSCRIPTION_USER_ACTION_CONTACT_ADMIN = "CONTACT_ADMIN" + + +def _subscriptionLimitsHint() -> str: + return " " + t( + "Details zu Ihrem Abonnement, den enthaltenen Limits und Upgrade-Optionen: " + "Menü «Administration» → «Billing» → Registerkarte «Abonnement»." + ) + + +def _enterpriseLimitsHint() -> str: + return " " + t( + "Ihr Enterprise-Abonnement wird vom Plattform-Administrator verwaltet. " + "Bitte kontaktieren Sie den Administrator für eine Anpassung der Limiten." + ) class SubscriptionCapacityException(Exception): - def __init__(self, resourceType: str, currentCount: int, maxAllowed: int, message: Optional[str] = None): + def __init__(self, resourceType: str, currentCount: int, maxAllowed: int, + message: Optional[str] = None, isEnterprise: bool = False): self.resourceType = resourceType self.currentCount = currentCount self.maxAllowed = maxAllowed + self.isEnterprise = isEnterprise + hint = _enterpriseLimitsHint() if isEnterprise else _subscriptionLimitsHint() if message is not None: self.message = message elif resourceType == "users": - self.message = ( - f"Mit dem aktuellen Abonnement sind für diesen Mandanten höchstens {maxAllowed} " - f"Benutzer zulässig (derzeit {currentCount}). " - f"Ohne Planwechsel können keine weiteren Benutzer hinzugefügt werden." - ) + _SUBSCRIPTION_LIMITS_UI_HINT_DE + self.message = t( + "Mit dem aktuellen Abonnement sind für diesen Mandanten höchstens {maxAllowed} " + "Benutzer zulässig (derzeit {currentCount}). " + "Ohne Planwechsel können keine weiteren Benutzer hinzugefügt werden." + ).format(maxAllowed=maxAllowed, currentCount=currentCount) + hint elif resourceType == "featureInstances": - self.message = ( - f"Es sind höchstens {maxAllowed} aktive Module erlaubt (derzeit {currentCount}). " - f"Bitte Abonnement erweitern oder ein Modul entfernen." - ) + _SUBSCRIPTION_LIMITS_UI_HINT_DE + self.message = t( + "Es sind höchstens {maxAllowed} aktive Module erlaubt (derzeit {currentCount}). " + "Bitte Abonnement erweitern oder ein Modul entfernen." + ).format(maxAllowed=maxAllowed, currentCount=currentCount) + hint elif resourceType == "dataVolumeMB": - self.message = ( - f"Das im Abonnement enthaltene Datenvolumen ({maxAllowed} MB) reicht nicht " - f"(aktuell ca. {currentCount} MB). Bitte Speicher-Limit oder Plan anpassen." - ) + _SUBSCRIPTION_LIMITS_UI_HINT_DE + self.message = t( + "Das im Abonnement enthaltene Datenvolumen ({maxAllowed} MB) reicht nicht " + "(aktuell ca. {currentCount} MB). Bitte Speicher-Limit oder Plan anpassen." + ).format(maxAllowed=maxAllowed, currentCount=currentCount) + hint else: - self.message = ( - f"Abonnement-Limit überschritten (Ressource «{resourceType}»: " - f"aktuell {currentCount}, erlaubt {maxAllowed})." - ) + _SUBSCRIPTION_LIMITS_UI_HINT_DE + self.message = t( + "Abonnement-Limit überschritten (Ressource «{resourceType}»: " + "aktuell {currentCount}, erlaubt {maxAllowed})." + ).format(resourceType=resourceType, currentCount=currentCount, maxAllowed=maxAllowed) + hint super().__init__(self.message) def toClientDict(self) -> Dict[str, Any]: + action = SUBSCRIPTION_USER_ACTION_CONTACT_ADMIN if self.isEnterprise else SUBSCRIPTION_USER_ACTION_UPGRADE return { "error": f"SUBSCRIPTION_{self.resourceType.upper()}_LIMIT", "currentCount": self.currentCount, "maxAllowed": self.maxAllowed, - "message": self.message, "userAction": SUBSCRIPTION_USER_ACTION_UPGRADE, + "message": self.message, "userAction": action, "subscriptionUiPath": "/admin/billing?tab=subscription", } diff --git a/tests/serviceGeneration/test_style_resolver.py b/tests/serviceGeneration/test_style_resolver.py index 6b2b649a..06f907ef 100644 --- a/tests/serviceGeneration/test_style_resolver.py +++ b/tests/serviceGeneration/test_style_resolver.py @@ -37,3 +37,10 @@ def test_full_style_passthrough(): result = resolveStyle(custom) assert result["fonts"]["primary"] == "Helvetica" assert result["fonts"]["monospace"] == "Monaco" + + +def test_override_document_title_partial_merge(): + result = resolveStyle({"documentTitle": {"sizePt": 32}}) + assert result["documentTitle"]["sizePt"] == 32 + assert result["documentTitle"]["align"] == "center" + assert result["headings"]["h1"]["sizePt"] == DEFAULT_STYLE["headings"]["h1"]["sizePt"]
PositionMenge × PreisTotal{t("Position")}{t("Menge")} × {t("Preis")}{t("Total")}
Netto-Total ({periodLabel}){t("Netto-Total")} ({periodLabel}){_chf(netTotal)}