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 from modules.shared.auditLogger import registerAuditLogCleanupScheduler
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 # Recover background jobs that were RUNNING when the previous worker died
try: try:
from modules.serviceCenter.services.serviceBackgroundJobs.mainBackgroundJobService import ( from modules.serviceCenter.services.serviceBackgroundJobs.mainBackgroundJobService import (

View file

@ -272,7 +272,9 @@ class ModelSelector:
return 1.0 return 1.0
elif requestedPriority == PriorityEnum.SPEED: 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: elif requestedPriority == PriorityEnum.QUALITY:
return model.qualityRating / 10.0 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 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 enum import Enum
from datetime import datetime, timezone from datetime import datetime, timezone
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@ -284,12 +284,63 @@ class MandateSubscription(PowerOnModel):
json_schema_extra={"label": "Stripe-Item (Instanzen)"}, 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) # Built-in plan catalog (static, no env dependency)
# ============================================================================ # ============================================================================
BUILTIN_PLANS: Dict[str, SubscriptionPlan] = { 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( "ROOT": SubscriptionPlan(
planKey="ROOT", planKey="ROOT",
selectableByUser=False, selectableByUser=False,
@ -415,3 +466,35 @@ def getPlan(planKey: str) -> Optional[SubscriptionPlan]:
def _getSelectablePlans() -> List[SubscriptionPlan]: def _getSelectablePlans() -> List[SubscriptionPlan]:
"""Return plans that users can choose in the UI.""" """Return plans that users can choose in the UI."""
return [p for p in BUILTIN_PLANS.values() if p.selectableByUser] 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"), "label": t("Session", context="UI"),
"meta": {"area": "session"} "meta": {"area": "session"}
}, },
{
"objectKey": "ui.feature.commcoach.dossier",
"label": t("Dossier", context="UI"),
"meta": {"area": "dossier"}
},
{ {
"objectKey": "ui.feature.commcoach.settings", "objectKey": "ui.feature.commcoach.settings",
"label": t("Einstellungen", context="UI"), "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.assistant", "view": True},
{"context": "UI", "item": "ui.feature.commcoach.modules", "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.session", "view": True},
{"context": "UI", "item": "ui.feature.commcoach.dossier", "view": True},
{"context": "UI", "item": "ui.feature.commcoach.settings", "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": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
{"context": "RESOURCE", "item": None, "view": False}, {"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.assistant", "view": True},
{"context": "UI", "item": "ui.feature.commcoach.modules", "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.session", "view": True},
{"context": "UI", "item": "ui.feature.commcoach.dossier", "view": True},
{"context": "UI", "item": "ui.feature.commcoach.settings", "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.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"}, {"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 return history
_TTS_WORD_LIMIT = 200 _TTS_WORD_LIMIT = 80
async def _prepareSpeechText(fullText: str, callAiFn) -> str: async def _prepareSpeechText(fullText: str, callAiFn) -> str:
@ -906,10 +906,14 @@ class CommcoachService:
) )
agentService = getService("agent", serviceContext) agentService = getService("agent", serviceContext)
from modules.datamodels.datamodelAi import PriorityEnum, OperationTypeEnum
config = AgentConfig( config = AgentConfig(
toolSet="commcoach" if useTools else "none", toolSet="commcoach" if useTools else "none",
maxRounds=3 if useTools else 1, maxRounds=3 if useTools else 1,
temperature=0.4, 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( buildRagContextFn = _createCommcoachRagFn(
@ -989,10 +993,14 @@ class CommcoachService:
) )
isFirstSession = not previousSessionSummaries or len(previousSessionSummaries) == 0 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": if persona and persona.get("key") != "coach":
personaLabel = persona.get("label", "Gesprächspartner") 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: 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." 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: else:

View file

@ -908,18 +908,22 @@ class BillingObjects:
) )
def reconcileMandateStorageBilling(self, mandateId: str) -> Optional[Dict[str, Any]]: 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) settings = self.getSettings(mandateId)
if not settings: if not settings:
return None return None
from modules.interfaces.interfaceDbSubscription import getRootInterface as _getSubRoot from modules.interfaces.interfaceDbSubscription import getRootInterface as _getSubRoot
from modules.datamodels.datamodelSubscription import getPlan from modules.datamodels.datamodelSubscription import getPlan, getEffectiveLimits
subIface = _getSubRoot() subIface = _getSubRoot()
usedMB = float(subIface.getMandateDataVolumeMB(mandateId)) usedMB = float(subIface.getMandateDataVolumeMB(mandateId))
sub = subIface.getOperativeForMandate(mandateId) sub = subIface.getOperativeForMandate(mandateId)
if sub and sub.get("isEnterprise"):
return None
plan = getPlan(sub.get("planKey", "")) if sub else 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: if includedMB is None:
return None return None
@ -966,27 +970,37 @@ class BillingObjects:
# Subscription AI-Budget Credit # 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. """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). Should be called once per billing period (initial activation + each invoice.paid).
Returns the created CREDIT transaction or None if budget is 0.""" 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) plan = getPlan(planKey)
if not plan or not plan.budgetAiPerUserCHF or plan.budgetAiPerUserCHF <= 0: if not plan or not plan.budgetAiPerUserCHF or plan.budgetAiPerUserCHF <= 0:
return None return None
from modules.interfaces.interfaceDbSubscription import getRootInterface as _getSubRoot from modules.interfaces.interfaceDbSubscription import getRootInterface as _getSubRoot
subRoot = _getSubRoot() subRoot = _getSubRoot()
activeUsers = max(subRoot.countActiveUsers(mandateId), 1) activeUsers = max(subRoot.countActiveUsers(mandateId), 1)
amount = plan.budgetAiPerUserCHF * activeUsers amount = plan.budgetAiPerUserCHF * activeUsers
description = f"AI-Budget ({planKey}, {activeUsers} User)"
if periodLabel:
description += f" {periodLabel}"
poolAccount = self.getOrCreateMandateAccount(mandateId) poolAccount = self.getOrCreateMandateAccount(mandateId)
description = f"AI-Budget ({planKey}, {activeUsers} User)"
if periodLabel:
description += f" {periodLabel}"
transaction = BillingTransaction( transaction = BillingTransaction(
accountId=poolAccount["id"], accountId=poolAccount["id"],
@ -998,8 +1012,8 @@ class BillingObjects:
) )
created = self.createTransaction(transaction) created = self.createTransaction(transaction)
logger.info( logger.info(
"AI-Budget credited mandate=%s plan=%s users=%d amount=%.2f CHF", "AI-Budget credited mandate=%s plan=%s amount=%.2f CHF",
mandateId, planKey, activeUsers, amount, mandateId, planKey, amount,
) )
return created return created
@ -1027,7 +1041,8 @@ class BillingObjects:
delta > 0: user added -> CREDIT pro-rata portion delta > 0: user added -> CREDIT pro-rata portion
delta < 0: user removed -> DEBIT 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 from modules.datamodels.datamodelSubscription import getPlan
plan = getPlan(planKey) plan = getPlan(planKey)
@ -1039,6 +1054,8 @@ class BillingObjects:
operative = subRoot.getOperativeForMandate(mandateId) operative = subRoot.getOperativeForMandate(mandateId)
if not operative: if not operative:
return None return None
if operative.get("isEnterprise"):
return None
periodStart = operative.get("currentPeriodStart") periodStart = operative.get("currentPeriodStart")
periodEnd = operative.get("currentPeriodEnd") periodEnd = operative.get("currentPeriodEnd")

View file

@ -37,8 +37,6 @@ logger = logging.getLogger(__name__)
managementDatabase = "poweron_management" managementDatabase = "poweron_management"
registerDatabase(managementDatabase) registerDatabase(managementDatabase)
# Singleton factory for Management instances with AI service per context
_instancesManagement = {}
# Custom exceptions for file handling # Custom exceptions for file handling
class FileError(Exception): class FileError(Exception):
@ -124,14 +122,12 @@ class ComponentObjects:
return None return None
def __del__(self): 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: if hasattr(self, 'db') and self.db is not None:
try: try:
self.db.close() self.db.close()
except Exception as e: except Exception:
logger.error(f"Error closing database connection: {e}") pass
logger.debug(f"User context set: userId={self.userId}")
def _initializeDatabase(self): def _initializeDatabase(self):
"""Initializes the database connection directly.""" """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': def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> 'ComponentObjects':
""" """
Returns a ComponentObjects instance. Returns a ComponentObjects instance scoped to the given user/mandate/featureInstance.
If currentUser is provided, initializes with user context.
Otherwise, returns an instance with only database access. Each call creates a lightweight instance whose DB connector is already
cached inside ``getCachedConnector``, so the overhead is minimal.
Args: Args:
currentUser: The authenticated user currentUser: The authenticated user
mandateId: The mandate ID from RequestContext (X-Mandate-Id header). Required. 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 effectiveMandateId = str(mandateId) if mandateId else None
effectiveFeatureInstanceId = str(featureInstanceId) if featureInstanceId else None effectiveFeatureInstanceId = str(featureInstanceId) if featureInstanceId else None
# Create new instance if not exists interface = ComponentObjects()
if "default" not in _instancesManagement:
_instancesManagement["default"] = ComponentObjects()
interface = _instancesManagement["default"]
if currentUser: if currentUser:
interface.setUserContext(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId) interface.setUserContext(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId)
else: else:
logger.info("Returning interface without user context") logger.info("Returning interface without user context")
return interface return interface

View file

@ -27,6 +27,7 @@ from modules.datamodels.datamodelSubscription import (
BUILTIN_PLANS, BUILTIN_PLANS,
getPlan as getPlanFromCatalog, getPlan as getPlanFromCatalog,
_getSelectablePlans, _getSelectablePlans,
getEffectiveLimits,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -276,33 +277,42 @@ class SubscriptionObjects:
) )
plan = self.getPlan(sub.get("planKey", "")) plan = self.getPlan(sub.get("planKey", ""))
if not plan: limits = getEffectiveLimits(sub, plan)
return True isEnterprise = sub.get("isEnterprise", False)
if resourceType == "users": if resourceType == "users":
cap = plan.maxUsers cap = limits["maxUsers"]
if cap is None: if cap is None:
return True return True
current = self.countActiveUsers(mandateId) current = self.countActiveUsers(mandateId)
if current + delta > cap: if current + delta > cap:
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException 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": elif resourceType == "featureInstances":
cap = plan.maxFeatureInstances cap = limits["maxFeatureInstances"]
if cap is None: if cap is None:
return True return True
current = self.countActiveFeatureInstances(mandateId) current = self.countActiveFeatureInstances(mandateId)
if current + delta > cap: if current + delta > cap:
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException 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": elif resourceType == "dataVolumeMB":
cap = plan.maxDataVolumeMB cap = limits["maxDataVolumeMB"]
if cap is None: if cap is None:
return True return True
currentMB = self.getMandateDataVolumeMB(mandateId) currentMB = self.getMandateDataVolumeMB(mandateId)
if currentMB + delta > cap: if currentMB + delta > cap:
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException
raise SubscriptionCapacityException(resourceType=resourceType, currentCount=int(currentMB), maxAllowed=cap) raise SubscriptionCapacityException(
resourceType=resourceType, currentCount=int(currentMB), maxAllowed=cap,
isEnterprise=isEnterprise,
)
return True return True
@ -325,10 +335,11 @@ class SubscriptionObjects:
if not sub: if not sub:
return None return None
plan = self.getPlan(sub.get("planKey", "")) 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 return None
usedMB = self.getMandateDataVolumeMB(mandateId) usedMB = self.getMandateDataVolumeMB(mandateId)
limitMB = plan.maxDataVolumeMB
percent = (usedMB / limitMB * 100) if limitMB > 0 else 0 percent = (usedMB / limitMB * 100) if limitMB > 0 else 0
if percent >= 80: if percent >= 80:
return {"usedMB": round(usedMB, 2), "limitMB": limitMB, "percent": round(percent, 1), "warning": True} 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 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.""" """Compute current usage metrics for a mandate's subscription."""
try: try:
from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelMembership import UserMandate from modules.datamodels.datamodelMembership import UserMandate
from modules.datamodels.datamodelFeatures import FeatureInstance from modules.datamodels.datamodelFeatures import FeatureInstance
from modules.interfaces.interfaceDbKnowledge import aggregateMandateRagTotalBytes from modules.interfaces.interfaceDbKnowledge import aggregateMandateRagTotalBytes
from modules.datamodels.datamodelSubscription import getEffectiveLimits
rootIf = getRootInterface() rootIf = getRootInterface()
@ -128,7 +129,8 @@ def _computeUsage(mandateId: str, plan) -> SubscriptionUsage:
ragBytes = aggregateMandateRagTotalBytes(mandateId) ragBytes = aggregateMandateRagTotalBytes(mandateId)
usedMB = round(ragBytes / (1024 * 1024), 2) 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 storagePercent = round((usedMB / maxMB) * 100, 1) if maxMB else None
return SubscriptionUsage( return SubscriptionUsage(
@ -207,7 +209,7 @@ def getStatus(request: Request, context: RequestContext = Depends(getRequestCont
plan = subService.getPlan(operative.get("planKey", "")) plan = subService.getPlan(operative.get("planKey", ""))
usage = _computeUsage(mandateId, plan) usage = _computeUsage(mandateId, plan, operative)
return SubscriptionStatusResponse( return SubscriptionStatusResponse(
active=True, active=True,
@ -451,13 +453,16 @@ def _buildEnrichedSubscriptions() -> List[Dict[str, Any]]:
sub["planTitle"] = resolveText(plan.title) if plan else planKey sub["planTitle"] = resolveText(plan.title) if plan else planKey
if sub.get("status") in operativeValues: 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) userCount = userCountMap.get(mid, 0)
instanceCount = instanceCountMap.get(mid, 0) instanceCount = instanceCountMap.get(mid, 0)
includedModules = plan.includedModules if plan else 0 if sub.get("isEnterprise"):
billableModules = max(0, instanceCount - includedModules) sub["monthlyRevenueCHF"] = round(sub.get("enterpriseFlatPriceCHF") or 0, 2)
sub["monthlyRevenueCHF"] = round(userPrice * userCount + instPrice * billableModules, 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["activeUsers"] = userCount
sub["activeInstances"] = instanceCount sub["activeInstances"] = instanceCount
else: else:
@ -570,13 +575,16 @@ def _getDataVolumeUsage(
ragBytes = aggregateMandateRagTotalBytes(mandateId) ragBytes = aggregateMandateRagTotalBytes(mandateId)
ragMB = round(ragBytes / (1024 * 1024), 2) ragMB = round(ragBytes / (1024 * 1024), 2)
from modules.datamodels.datamodelSubscription import getEffectiveLimits
maxMB = None maxMB = None
subIf = _getSubRootIf() subIf = _getSubRootIf()
operative = subIf.getOperativeForMandate(mandateId) operative = subIf.getOperativeForMandate(mandateId)
if operative: if operative:
plan = subIf.getPlan(operative.get("planKey") or "") plan = subIf.getPlan(operative.get("planKey") or "")
if plan and plan.maxDataVolumeMB is not None: limits = getEffectiveLimits(operative, plan)
maxMB = int(plan.maxDataVolumeMB) if limits["maxDataVolumeMB"] is not None:
maxMB = int(limits["maxDataVolumeMB"])
usedMB = ragMB usedMB = ragMB
percentUsed = round((usedMB / maxMB) * 100, 1) if maxMB else None percentUsed = round((usedMB / maxMB) * 100, 1) if maxMB else None
@ -593,3 +601,147 @@ def _getDataVolumeUsage(
"percentUsed": percentUsed, "percentUsed": percentUsed,
"warning": (percentUsed or 0) >= 80, "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__) 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( async def runAgentLoop(
prompt: str, prompt: str,
toolRegistry: ToolRegistry, toolRegistry: ToolRegistry,
@ -75,15 +113,20 @@ async def runAgentLoop(
featureInstanceId=featureInstanceId featureInstanceId=featureInstanceId
) )
activeToolSet = config.toolSet if config else None if config and config.excludeAllTools:
tools = toolRegistry.getTools(toolSet=activeToolSet) tools = []
toolDefinitions = toolRegistry.formatToolsForFunctionCalling(toolSet=activeToolSet) 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 # Text-based tool descriptions are ONLY used as fallback when native function
# calling is unavailable. Including both creates conflicting instructions # calling is unavailable. Including both creates conflicting instructions
# (text ```tool_call format vs native tool_use blocks) and can cause the model # (text ```tool_call format vs native tool_use blocks) and can cause the model
# to respond with plain text instead of actual tool calls. # to respond with plain text instead of actual tool calls.
toolsText = "" if toolDefinitions else toolRegistry.formatToolsForPrompt(toolSet=activeToolSet) toolsText = "" if toolDefinitions else toolRegistry.formatToolsForPrompt(toolSet=activeToolSet)
if systemPromptOverride: if systemPromptOverride:
systemPrompt = systemPromptOverride systemPrompt = systemPromptOverride
@ -100,7 +143,7 @@ async def runAgentLoop(
roundStartTime = time.time() roundStartTime = time.time()
roundLog = AgentRoundLog(roundNumber=state.currentRound) 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: if buildRagContextFn:
try: try:
latestUserMsg = "" latestUserMsg = ""
@ -108,9 +151,12 @@ async def runAgentLoop(
if msg.get("role") == "user": if msg.get("role") == "user":
latestUserMsg = msg.get("content", "") latestUserMsg = msg.get("content", "")
break break
ragContext = await buildRagContextFn( isConversational = config and config.excludeAllTools
ragContext = await _getOrRefreshRag(
workflowId,
buildRagContextFn,
forceRefresh=not isConversational,
currentPrompt=latestUserMsg or prompt, currentPrompt=latestUserMsg or prompt,
workflowId=workflowId,
userId=userId, userId=userId,
featureInstanceId=featureInstanceId, featureInstanceId=featureInstanceId,
mandateId=mandateId, mandateId=mandateId,
@ -166,12 +212,15 @@ async def runAgentLoop(
) )
# AI call # AI call
aiOptions = AiCallOptions(
operationType=config.operationType or OperationTypeEnum.AGENT,
temperature=config.temperature,
)
if config.priority:
aiOptions.priority = config.priority
aiRequest = AiCallRequest( aiRequest = AiCallRequest(
prompt="", prompt="",
options=AiCallOptions( options=aiOptions,
operationType=config.operationType or OperationTypeEnum.AGENT,
temperature=config.temperature
),
messages=conversation.messages, messages=conversation.messages,
tools=toolDefinitions if toolDefinitions else None, tools=toolDefinitions if toolDefinitions else None,
) )

View file

@ -6,7 +6,7 @@ from typing import List, Dict, Any, Optional
from enum import Enum from enum import Enum
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from modules.shared.timeUtils import getUtcTimestamp from modules.shared.timeUtils import getUtcTimestamp
from modules.datamodels.datamodelAi import OperationTypeEnum from modules.datamodels.datamodelAi import OperationTypeEnum, PriorityEnum
import uuid import uuid
@ -101,6 +101,18 @@ class AgentConfig(BaseModel):
"manipulate the workflow graph, not execute its actions." "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): class AgentState(BaseModel):

View file

@ -118,12 +118,28 @@ class BaseRenderer(ABC):
para = style["paragraph"] para = style["paragraph"]
lst = style["list"] lst = style["list"]
cb = style["codeBlock"] 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 { return {
"title": { "title": {
"font_size": h1["sizePt"], "color": h1["color"], "font_size": titleSizePt,
"bold": h1.get("weight") == "bold", "align": "left", "color": titleColor,
"space_before": 0, "bold": titleBold,
"space_after": h1.get("spaceAfterPt", 8), "align": titleAlign,
"space_before": titleSpaceBefore,
"space_after": titleSpaceAfter,
}, },
"heading1": { "heading1": {
"font_size": h1["sizePt"], "color": h1["color"], "font_size": h1["sizePt"], "color": h1["color"],

View file

@ -146,8 +146,8 @@ class RendererHtml(BaseRenderer):
htmlParts.append('</head>') htmlParts.append('</head>')
htmlParts.append('<body>') htmlParts.append('<body>')
# Document header # Document header (not an h1 — body headings keep a single outline level scale)
htmlParts.append(f'<header><h1 class="document-title">{documentTitle}</h1></header>') htmlParts.append(f'<header><p class="document-title">{documentTitle}</p></header>')
# Main content # Main content
htmlParts.append('<main>') htmlParts.append('<main>')
@ -412,16 +412,27 @@ class RendererHtml(BaseRenderer):
css_parts.append(" margin: 0; padding: 20px;") css_parts.append(" margin: 0; padding: 20px;")
css_parts.append("}") css_parts.append("}")
# Document title (uses h1 style) docTitle = style.get("documentTitle") if isinstance(style.get("documentTitle"), dict) else {}
h1 = headings.get("h1", {}) 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(".document-title {")
css_parts.append(f" font-size: {h1.get('sizePt', 24)}pt;") css_parts.append(f" font-size: {dtSize}pt;")
css_parts.append(f" color: {h1.get('color', primaryColor)};") css_parts.append(f" color: {dtColor};")
css_parts.append(f" font-weight: {h1.get('weight', 'bold')};") css_parts.append(f" font-weight: {dtWeight};")
css_parts.append(" margin: 0 0 1em 0;") css_parts.append(f" text-align: {dtAlign};")
css_parts.append(" margin: 0;")
css_parts.append(f" margin-bottom: {dtSpaceAfter}pt;")
css_parts.append("}") css_parts.append("}")
# Headings h1-h4 # Headings h1-h4
h1 = headings.get("h1", {})
for level in range(1, 5): for level in range(1, 5):
key = f"h{level}" key = f"h{level}"
h = headings.get(key, h1 if level == 1 else headings.get(f"h{level-1}", {})) 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: if text:
level = max(1, min(6, level)) level = max(1, min(6, level))
return f"{'#' * level} {text}" md_level = min(6, level + 1)
return f"{'#' * md_level} {text}"
return "" return ""

View file

@ -192,6 +192,7 @@ class RendererPdf(BaseRenderer):
# Extract sections and metadata from standardized schema # Extract sections and metadata from standardized schema
sections = self._extractSections(json_content) sections = self._extractSections(json_content)
metadata = self._extractMetadata(json_content)
# Create a buffer to hold the PDF # Create a buffer to hold the PDF
buffer = io.BytesIO() buffer = io.BytesIO()
@ -204,8 +205,13 @@ class RendererPdf(BaseRenderer):
else: else:
doc = SimpleDocTemplate(buffer, pagesize=A4, rightMargin=72, leftMargin=72, topMargin=72, bottomMargin=18) 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 = [] 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) # Process each section (sections already extracted above)
self.services.utils.debugLogToFile(f"PDF SECTIONS TO PROCESS: {len(sections)} sections", "PDF_RENDERER") self.services.utils.debugLogToFile(f"PDF SECTIONS TO PROCESS: {len(sections)} sections", "PDF_RENDERER")
@ -561,6 +567,22 @@ class RendererPdf(BaseRenderer):
"space_before": sb, "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: def _createHeadingStyle(self, styles: Dict[str, Any], level: int) -> ParagraphStyle:
"""Create heading style from style definitions.""" """Create heading style from style definitions."""
heading_key = f"heading{level}" heading_key = f"heading{level}"

View file

@ -340,11 +340,8 @@ class RendererText(BaseRenderer):
except (TypeError, ValueError): except (TypeError, ValueError):
level_i = 1 level_i = 1
level_i = max(1, min(6, level_i)) level_i = max(1, min(6, level_i))
if level_i == 1: md_level = min(6, level_i + 1)
return f"{text}\n{'=' * len(text)}" return f"{'#' * md_level} {text}"
if level_i == 2:
return f"{text}\n{'-' * len(text)}"
return f"{'#' * level_i} {text}"
except Exception as e: except Exception as e:
self.logger.warning(f"Error rendering heading: {str(e)}") self.logger.warning(f"Error rendering heading: {str(e)}")

View file

@ -16,8 +16,16 @@ DEFAULT_STYLE: Dict[str, Any] = {
"accent": "#2980B9", "accent": "#2980B9",
"background": "#FFFFFF", "background": "#FFFFFF",
}, },
"documentTitle": {
"sizePt": 28,
"weight": "bold",
"color": "#1F3864",
"spaceBeforePt": 0,
"spaceAfterPt": 18,
"align": "center",
},
"headings": { "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}, "h2": {"sizePt": 18, "weight": "bold", "color": "#1F3864", "spaceBeforePt": 20, "spaceAfterPt": 6},
"h3": {"sizePt": 14, "weight": "bold", "color": "#2C3E50", "spaceBeforePt": 16, "spaceAfterPt": 4}, "h3": {"sizePt": 14, "weight": "bold", "color": "#2C3E50", "spaceBeforePt": 16, "spaceAfterPt": 4},
"h4": {"sizePt": 12, "weight": "bold", "color": "#2C3E50", "spaceBeforePt": 12, "spaceAfterPt": 3}, "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, getInterface as getSubscriptionInterface,
InvalidTransitionError, InvalidTransitionError,
) )
from modules.shared.i18nRegistry import t
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -581,6 +582,256 @@ class SubscriptionService:
def syncStripeQuantity(self, subscriptionId: str): def syncStripeQuantity(self, subscriptionId: str):
self._interface.syncQuantityToStripe(subscriptionId) 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 # Notifications
@ -608,66 +859,66 @@ def _notifySubscriptionChange(
templates: Dict[str, Dict[str, Any]] = { templates: Dict[str, Dict[str, Any]] = {
"activated": { "activated": {
"subject": f"[PowerOn] Abonnement aktiviert — {planLabel}", "subject": f"[PowerOn] {t('Abonnement aktiviert')}{planLabel}",
"headline": "Abonnement aktiviert", "headline": t("Abonnement aktiviert"),
"paragraphs": [ "paragraphs": [
p for p in [ 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, 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 ] if p
], ],
}, },
"cancelled": { "cancelled": {
"subject": f"[PowerOn] Abonnement gekündigt — {planLabel}", "subject": f"[PowerOn] {t('Abonnement gekündigt')}{planLabel}",
"headline": "Abonnement gekündigt", "headline": t("Abonnement gekündigt"),
"paragraphs": [ "paragraphs": [
p for p in [ p for p in [
f"Das Abonnement «{planLabel}» wurde gekündigt.", t("Das Abonnement «{planLabel}» wurde gekündigt.").format(planLabel=planLabel),
platformHint, 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 ] if p
], ],
}, },
"force_cancelled": { "force_cancelled": {
"subject": f"[PowerOn] Abonnement sofort beendet — {planLabel}", "subject": f"[PowerOn] {t('Abonnement sofort beendet')}{planLabel}",
"headline": "Abonnement sofort beendet", "headline": t("Abonnement sofort beendet"),
"paragraphs": [ "paragraphs": [
p for p in [ 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, 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 ] if p
], ],
}, },
"trial_expired": { "trial_expired": {
"subject": "[PowerOn] Testphase abgelaufen", "subject": f"[PowerOn] {t('Testphase abgelaufen')}",
"headline": "Testphase abgelaufen", "headline": t("Testphase abgelaufen"),
"paragraphs": [ "paragraphs": [
p for p in [ p for p in [
"Die kostenlose Testphase ist abgelaufen.", t("Die kostenlose Testphase ist abgelaufen."),
platformHint, 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 ] if p
], ],
}, },
"payment_failed": { "payment_failed": {
"subject": f"[PowerOn] Zahlung fehlgeschlagen — {planLabel}", "subject": f"[PowerOn] {t('Zahlung fehlgeschlagen')}{planLabel}",
"headline": "Zahlung fehlgeschlagen", "headline": t("Zahlung fehlgeschlagen"),
"paragraphs": [ "paragraphs": [
p for p in [ 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, platformHint,
"Bitte aktualisieren Sie Ihr Zahlungsmittel unter Billing-Verwaltung.", t("Bitte aktualisieren Sie Ihr Zahlungsmittel unter Billing-Verwaltung."),
] if p ] if p
], ],
}, },
} }
tpl = templates.get(event, { tpl = templates.get(event, {
"subject": f"[PowerOn] Abonnement-Änderung — {planLabel}", "subject": f"[PowerOn] {t('Abonnement-Änderung')}{planLabel}",
"headline": "Abonnement-Änderung", "headline": t("Abonnement-Änderung"),
"paragraphs": [f"Änderung am Abonnement «{planLabel}»."], "paragraphs": [t("Änderung am Abonnement «{planLabel}».").format(planLabel=planLabel)],
}) })
notifyMandateAdmins( notifyMandateAdmins(
@ -699,7 +950,7 @@ def _buildInvoiceSummaryHtml(
instanceTotal = billableModules * instancePrice instanceTotal = billableModules * instancePrice
netTotal = userTotal + instanceTotal 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: def _chf(amount: float) -> str:
return f"CHF {amount:,.2f}".replace(",", "'") return f"CHF {amount:,.2f}".replace(",", "'")
@ -707,13 +958,13 @@ def _buildInvoiceSummaryHtml(
rows = "" rows = ""
if userPrice > 0: if userPrice > 0:
rows += ( 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 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' 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: if instancePrice > 0 and billableModules > 0:
rows += ( 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 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' 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 = ( invoiceLink = (
f'<p style="margin:12px 0 0 0;font-size:14px;">' f'<p style="margin:12px 0 0 0;font-size:14px;">'
f'<a href="{htmlmod.escape(hostedUrl)}" style="color:#3b82f6;text-decoration:underline;">' 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: except Exception as e:
logger.warning("Could not fetch Stripe invoice URL for sub %s: %s", stripeSubId, e) logger.warning("Could not fetch Stripe invoice URL for sub %s: %s", stripeSubId, e)
@ -741,13 +992,13 @@ def _buildInvoiceSummaryHtml(
return ( return (
f'<table style="width:100%;border-collapse:collapse;font-size:14px;margin:8px 0;">' f'<table style="width:100%;border-collapse:collapse;font-size:14px;margin:8px 0;">'
f'<thead><tr style="border-bottom:2px solid #e5e7eb;">' 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: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;">Menge × Preis</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;">Total</th>' f'<th style="text-align:right;padding:8px 0;color:#6b7280;font-weight:500;">{t("Total")}</th>'
f'</tr></thead>' f'</tr></thead>'
f'<tbody>{rows}</tbody>' f'<tbody>{rows}</tbody>'
f'<tfoot><tr style="border-top:2px solid #1a1a2e;">' 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></td>'
f'<td style="padding:10px 0;text-align:right;font-weight:700;color:#1a1a2e;font-size:16px;">{_chf(netTotal)}</td>' f'<td style="padding:10px 0;text-align:right;font-weight:700;color:#1a1a2e;font-size:16px;">{_chf(netTotal)}</td>'
f'</tr></tfoot>' f'</tr></tfoot>'
@ -776,7 +1027,7 @@ def _buildCancelSummaryHtml(subRecord: Dict[str, Any], platformUrl: str = "") ->
parts.append( parts.append(
f'<p style="margin:4px 0;font-size:14px;">' f'<p style="margin:4px 0;font-size:14px;">'
f'<a href="{htmlmod.escape(hostedUrl)}" style="color:#3b82f6;text-decoration:underline;">' 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: except Exception as e:
logger.warning("Could not fetch Stripe invoice URL for sub %s: %s", stripeSubId, 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.mandateId = mandateId
self.reason = _subscriptionReasonForStatus(status) self.reason = _subscriptionReasonForStatus(status)
self.userAction = _subscriptionUserActionForStatus(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." "Kein aktives Abonnement für diesen Mandanten. Bitte wählen Sie einen Plan unter Billing."
) )
super().__init__(self.message) super().__init__(self.message)
@ -837,47 +1088,62 @@ class SubscriptionInactiveException(Exception):
return out return out
_SUBSCRIPTION_LIMITS_UI_HINT_DE = ( SUBSCRIPTION_USER_ACTION_CONTACT_ADMIN = "CONTACT_ADMIN"
" Details zu Ihrem Abonnement, den enthaltenen Limits und Upgrade-Optionen: "
"Menü «Administration» → «Billing» → Registerkarte «Abonnement»."
) 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): 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.resourceType = resourceType
self.currentCount = currentCount self.currentCount = currentCount
self.maxAllowed = maxAllowed self.maxAllowed = maxAllowed
self.isEnterprise = isEnterprise
hint = _enterpriseLimitsHint() if isEnterprise else _subscriptionLimitsHint()
if message is not None: if message is not None:
self.message = message self.message = message
elif resourceType == "users": elif resourceType == "users":
self.message = ( self.message = t(
f"Mit dem aktuellen Abonnement sind für diesen Mandanten höchstens {maxAllowed} " "Mit dem aktuellen Abonnement sind für diesen Mandanten höchstens {maxAllowed} "
f"Benutzer zulässig (derzeit {currentCount}). " "Benutzer zulässig (derzeit {currentCount}). "
f"Ohne Planwechsel können keine weiteren Benutzer hinzugefügt werden." "Ohne Planwechsel können keine weiteren Benutzer hinzugefügt werden."
) + _SUBSCRIPTION_LIMITS_UI_HINT_DE ).format(maxAllowed=maxAllowed, currentCount=currentCount) + hint
elif resourceType == "featureInstances": elif resourceType == "featureInstances":
self.message = ( self.message = t(
f"Es sind höchstens {maxAllowed} aktive Module erlaubt (derzeit {currentCount}). " "Es sind höchstens {maxAllowed} aktive Module erlaubt (derzeit {currentCount}). "
f"Bitte Abonnement erweitern oder ein Modul entfernen." "Bitte Abonnement erweitern oder ein Modul entfernen."
) + _SUBSCRIPTION_LIMITS_UI_HINT_DE ).format(maxAllowed=maxAllowed, currentCount=currentCount) + hint
elif resourceType == "dataVolumeMB": elif resourceType == "dataVolumeMB":
self.message = ( self.message = t(
f"Das im Abonnement enthaltene Datenvolumen ({maxAllowed} MB) reicht nicht " "Das im Abonnement enthaltene Datenvolumen ({maxAllowed} MB) reicht nicht "
f"(aktuell ca. {currentCount} MB). Bitte Speicher-Limit oder Plan anpassen." "(aktuell ca. {currentCount} MB). Bitte Speicher-Limit oder Plan anpassen."
) + _SUBSCRIPTION_LIMITS_UI_HINT_DE ).format(maxAllowed=maxAllowed, currentCount=currentCount) + hint
else: else:
self.message = ( self.message = t(
f"Abonnement-Limit überschritten (Ressource «{resourceType}»: " "Abonnement-Limit überschritten (Ressource «{resourceType}»: "
f"aktuell {currentCount}, erlaubt {maxAllowed})." "aktuell {currentCount}, erlaubt {maxAllowed})."
) + _SUBSCRIPTION_LIMITS_UI_HINT_DE ).format(resourceType=resourceType, currentCount=currentCount, maxAllowed=maxAllowed) + hint
super().__init__(self.message) super().__init__(self.message)
def toClientDict(self) -> Dict[str, Any]: def toClientDict(self) -> Dict[str, Any]:
action = SUBSCRIPTION_USER_ACTION_CONTACT_ADMIN if self.isEnterprise else SUBSCRIPTION_USER_ACTION_UPGRADE
return { return {
"error": f"SUBSCRIPTION_{self.resourceType.upper()}_LIMIT", "error": f"SUBSCRIPTION_{self.resourceType.upper()}_LIMIT",
"currentCount": self.currentCount, "maxAllowed": self.maxAllowed, "currentCount": self.currentCount, "maxAllowed": self.maxAllowed,
"message": self.message, "userAction": SUBSCRIPTION_USER_ACTION_UPGRADE, "message": self.message, "userAction": action,
"subscriptionUiPath": "/admin/billing?tab=subscription", "subscriptionUiPath": "/admin/billing?tab=subscription",
} }

View file

@ -37,3 +37,10 @@ def test_full_style_passthrough():
result = resolveStyle(custom) result = resolveStyle(custom)
assert result["fonts"]["primary"] == "Helvetica" assert result["fonts"]["primary"] == "Helvetica"
assert result["fonts"]["monospace"] == "Monaco" 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"]