Merge pull request #158 from valueonag/feat/demo-system-readieness

abo enterprise, ai agent fixes
This commit is contained in:
Patrick Motsch 2026-05-10 22:11:05 +02:00 committed by GitHub
commit fac191fc77
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 902 additions and 161 deletions

4
app.py
View file

@ -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 (

View file

@ -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

View file

@ -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,
}

View file

@ -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"},

View file

@ -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:

View file

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

View file

@ -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

View file

@ -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}

View file

@ -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))

View file

@ -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,
)

View file

@ -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):

View file

@ -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"],

View file

@ -146,8 +146,8 @@ class RendererHtml(BaseRenderer):
htmlParts.append('</head>')
htmlParts.append('<body>')
# Document header
htmlParts.append(f'<header><h1 class="document-title">{documentTitle}</h1></header>')
# Document header (not an h1 — body headings keep a single outline level scale)
htmlParts.append(f'<header><p class="document-title">{documentTitle}</p></header>')
# Main content
htmlParts.append('<main>')
@ -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}", {}))

View file

@ -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 ""

View file

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

View file

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

View file

@ -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},

View file

@ -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)

View file

@ -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'<tr><td style="padding:4px 0;color:#555;">{t("Benutzer")}</td>'
f'<td style="padding:4px 0;text-align:right;color:#333;">max. {maxUsers}</td></tr>'
)
if maxFeatures is not None:
detailRows += (
f'<tr><td style="padding:4px 0;color:#555;">{t("Module")}</td>'
f'<td style="padding:4px 0;text-align:right;color:#333;">max. {maxFeatures}</td></tr>'
)
if maxStorageMB is not None:
storageLabel = f"{maxStorageMB} MB" if maxStorageMB < 1024 else f"{maxStorageMB / 1024:.1f} GB"
detailRows += (
f'<tr><td style="padding:4px 0;color:#555;">{t("Datenvolumen")}</td>'
f'<td style="padding:4px 0;text-align:right;color:#333;">max. {storageLabel}</td></tr>'
)
if budgetAi is not None and budgetAi > 0:
detailRows += (
f'<tr><td style="padding:4px 0;color:#555;">{t("KI-Budget")}</td>'
f'<td style="padding:4px 0;text-align:right;color:#333;">{_chf(budgetAi)}</td></tr>'
)
noteHtml = ""
if note:
import html as htmlmod
noteHtml = (
f'<p style="margin:8px 0 0 0;font-size:13px;color:#6b7280;">'
f'{t("Notiz")}: {htmlmod.escape(note)}</p>'
)
return (
f'<table style="width:100%;border-collapse:collapse;font-size:14px;margin:8px 0;">'
f'<tbody>'
f'<tr style="border-bottom:1px solid #e5e7eb;">'
f'<td style="padding:6px 0;color:#555;">{t("Zeitraum")}</td>'
f'<td style="padding:6px 0;text-align:right;color:#333;">{_fmtDate(periodStart)} {_fmtDate(periodEnd)}</td>'
f'</tr>'
f'{detailRows}'
f'<tr style="border-top:2px solid #1a1a2e;">'
f'<td style="padding:10px 0;font-weight:700;color:#1a1a2e;">{t("Pauschale")}</td>'
f'<td style="padding:10px 0;text-align:right;font-weight:700;color:#1a1a2e;font-size:16px;">'
f'{_chf(flatPrice)}</td>'
f'</tr>'
f'</tbody>'
f'</table>'
f'<p style="margin:8px 0 0 0;font-size:13px;color:#6b7280;">'
f'{t("Zahlungsfrist")}: {t("10 Tage")}</p>'
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'<tr><td style="padding:6px 0;color:#333;">Benutzer-Lizenzen</td>'
f'<tr><td style="padding:6px 0;color:#333;">{t("Benutzer-Lizenzen")}</td>'
f'<td style="padding:6px 8px;color:#555;text-align:right;">{userCount} × {_chf(userPrice)}</td>'
f'<td style="padding:6px 0;color:#333;text-align:right;font-weight:600;">{_chf(userTotal)}</td></tr>\n'
)
if instancePrice > 0 and billableModules > 0:
rows += (
f'<tr><td style="padding:6px 0;color:#333;">Module ({instanceCount} total, {plan.includedModules} inkl.)</td>'
f'<tr><td style="padding:6px 0;color:#333;">{t("Module")} ({instanceCount} total, {plan.includedModules} {t("inkl.")})</td>'
f'<td style="padding:6px 8px;color:#555;text-align:right;">{billableModules} × {_chf(instancePrice)}</td>'
f'<td style="padding:6px 0;color:#333;text-align:right;font-weight:600;">{_chf(instanceTotal)}</td></tr>\n'
)
@ -733,7 +984,7 @@ def _buildInvoiceSummaryHtml(
invoiceLink = (
f'<p style="margin:12px 0 0 0;font-size:14px;">'
f'<a href="{htmlmod.escape(hostedUrl)}" style="color:#3b82f6;text-decoration:underline;">'
f'Vollständige Rechnung mit MwSt-Ausweis anzeigen</a></p>\n'
f'{t("Vollständige Rechnung mit MwSt-Ausweis anzeigen")}</a></p>\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'<table style="width:100%;border-collapse:collapse;font-size:14px;margin:8px 0;">'
f'<thead><tr style="border-bottom:2px solid #e5e7eb;">'
f'<th style="text-align:left;padding:8px 0;color:#6b7280;font-weight:500;">Position</th>'
f'<th style="text-align:right;padding:8px;color:#6b7280;font-weight:500;">Menge × Preis</th>'
f'<th style="text-align:right;padding:8px 0;color:#6b7280;font-weight:500;">Total</th>'
f'<th style="text-align:left;padding:8px 0;color:#6b7280;font-weight:500;">{t("Position")}</th>'
f'<th style="text-align:right;padding:8px;color:#6b7280;font-weight:500;">{t("Menge")} × {t("Preis")}</th>'
f'<th style="text-align:right;padding:8px 0;color:#6b7280;font-weight:500;">{t("Total")}</th>'
f'</tr></thead>'
f'<tbody>{rows}</tbody>'
f'<tfoot><tr style="border-top:2px solid #1a1a2e;">'
f'<td style="padding:10px 0;font-weight:700;color:#1a1a2e;">Netto-Total ({periodLabel})</td>'
f'<td style="padding:10px 0;font-weight:700;color:#1a1a2e;">{t("Netto-Total")} ({periodLabel})</td>'
f'<td></td>'
f'<td style="padding:10px 0;text-align:right;font-weight:700;color:#1a1a2e;font-size:16px;">{_chf(netTotal)}</td>'
f'</tr></tfoot>'
@ -776,7 +1027,7 @@ def _buildCancelSummaryHtml(subRecord: Dict[str, Any], platformUrl: str = "") ->
parts.append(
f'<p style="margin:4px 0;font-size:14px;">'
f'<a href="{htmlmod.escape(hostedUrl)}" style="color:#3b82f6;text-decoration:underline;">'
f'Letzte Stripe-Rechnung anzeigen</a></p>'
f'{t("Letzte Stripe-Rechnung anzeigen")}</a></p>'
)
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",
}

View file

@ -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"]