')
@@ -412,16 +412,27 @@ class RendererHtml(BaseRenderer):
css_parts.append(" margin: 0; padding: 20px;")
css_parts.append("}")
- # Document title (uses h1 style)
- h1 = headings.get("h1", {})
+ docTitle = style.get("documentTitle") if isinstance(style.get("documentTitle"), dict) else {}
+ dtSize = docTitle.get("sizePt")
+ if dtSize is None:
+ dtSize = max(headings.get("h1", {}).get("sizePt", 22) + 4, 26)
+ dtColor = docTitle.get("color", primaryColor)
+ dtWeight = docTitle.get("weight", "bold")
+ dtAlign = docTitle.get("align", "center")
+ if dtAlign not in ("left", "center", "right"):
+ dtAlign = "center"
+ dtSpaceAfter = docTitle.get("spaceAfterPt", 18)
css_parts.append(".document-title {")
- css_parts.append(f" font-size: {h1.get('sizePt', 24)}pt;")
- css_parts.append(f" color: {h1.get('color', primaryColor)};")
- css_parts.append(f" font-weight: {h1.get('weight', 'bold')};")
- css_parts.append(" margin: 0 0 1em 0;")
+ css_parts.append(f" font-size: {dtSize}pt;")
+ css_parts.append(f" color: {dtColor};")
+ css_parts.append(f" font-weight: {dtWeight};")
+ css_parts.append(f" text-align: {dtAlign};")
+ css_parts.append(" margin: 0;")
+ css_parts.append(f" margin-bottom: {dtSpaceAfter}pt;")
css_parts.append("}")
# Headings h1-h4
+ h1 = headings.get("h1", {})
for level in range(1, 5):
key = f"h{level}"
h = headings.get(key, h1 if level == 1 else headings.get(f"h{level-1}", {}))
diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererMarkdown.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererMarkdown.py
index 1113f1a2..84649ae7 100644
--- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererMarkdown.py
+++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererMarkdown.py
@@ -289,7 +289,8 @@ class RendererMarkdown(BaseRenderer):
if text:
level = max(1, min(6, level))
- return f"{'#' * level} {text}"
+ md_level = min(6, level + 1)
+ return f"{'#' * md_level} {text}"
return ""
diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py
index 8ba20c6a..f75a5108 100644
--- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py
+++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py
@@ -192,6 +192,7 @@ class RendererPdf(BaseRenderer):
# Extract sections and metadata from standardized schema
sections = self._extractSections(json_content)
+ metadata = self._extractMetadata(json_content)
# Create a buffer to hold the PDF
buffer = io.BytesIO()
@@ -204,8 +205,13 @@ class RendererPdf(BaseRenderer):
else:
doc = SimpleDocTemplate(buffer, pagesize=A4, rightMargin=72, leftMargin=72, topMargin=72, bottomMargin=18)
- # Build PDF content (no cover page — body starts on page 1; filename still uses `title`)
+ # Body starts on page 1 — optional document title uses styles["title"] (distinct from H1)
story = []
+ document_title = (title or "").strip()
+ if not document_title and isinstance(metadata, dict):
+ document_title = (metadata.get("title") or "").strip()
+ if document_title:
+ story.append(self._paragraphFromInlineMarkdown(document_title, self._createDocumentTitleStyle(styles)))
# Process each section (sections already extracted above)
self.services.utils.debugLogToFile(f"PDF SECTIONS TO PROCESS: {len(sections)} sections", "PDF_RENDERER")
@@ -561,6 +567,22 @@ class RendererPdf(BaseRenderer):
"space_before": sb,
}
+ def _createDocumentTitleStyle(self, styles: Dict[str, Any]) -> ParagraphStyle:
+ """Paragraph style for the document title (metadata/doc title — not heading level 1)."""
+ title_style_def = styles.get("title") or {}
+ fs = title_style_def.get("font_size", 26)
+ bold = title_style_def.get("bold", True)
+ return ParagraphStyle(
+ "DocumentTitle",
+ fontName="Helvetica-Bold" if bold else "Helvetica",
+ fontSize=fs,
+ spaceAfter=title_style_def.get("space_after", 18),
+ spaceBefore=title_style_def.get("space_before", 0),
+ alignment=self._getAlignment(title_style_def.get("align", "center")),
+ textColor=self._hexToColor(title_style_def.get("color", "#1F3864")),
+ leading=fs * 1.25,
+ )
+
def _createHeadingStyle(self, styles: Dict[str, Any], level: int) -> ParagraphStyle:
"""Create heading style from style definitions."""
heading_key = f"heading{level}"
diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererText.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererText.py
index 67eab4e8..1af2aec5 100644
--- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererText.py
+++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererText.py
@@ -340,11 +340,8 @@ class RendererText(BaseRenderer):
except (TypeError, ValueError):
level_i = 1
level_i = max(1, min(6, level_i))
- if level_i == 1:
- return f"{text}\n{'=' * len(text)}"
- if level_i == 2:
- return f"{text}\n{'-' * len(text)}"
- return f"{'#' * level_i} {text}"
+ md_level = min(6, level_i + 1)
+ return f"{'#' * md_level} {text}"
except Exception as e:
self.logger.warning(f"Error rendering heading: {str(e)}")
diff --git a/modules/serviceCenter/services/serviceGeneration/styleDefaults.py b/modules/serviceCenter/services/serviceGeneration/styleDefaults.py
index 1984f18d..6d890f29 100644
--- a/modules/serviceCenter/services/serviceGeneration/styleDefaults.py
+++ b/modules/serviceCenter/services/serviceGeneration/styleDefaults.py
@@ -16,8 +16,16 @@ DEFAULT_STYLE: Dict[str, Any] = {
"accent": "#2980B9",
"background": "#FFFFFF",
},
+ "documentTitle": {
+ "sizePt": 28,
+ "weight": "bold",
+ "color": "#1F3864",
+ "spaceBeforePt": 0,
+ "spaceAfterPt": 18,
+ "align": "center",
+ },
"headings": {
- "h1": {"sizePt": 24, "weight": "bold", "color": "#1F3864", "spaceBeforePt": 24, "spaceAfterPt": 8},
+ "h1": {"sizePt": 22, "weight": "bold", "color": "#1F3864", "spaceBeforePt": 22, "spaceAfterPt": 8},
"h2": {"sizePt": 18, "weight": "bold", "color": "#1F3864", "spaceBeforePt": 20, "spaceAfterPt": 6},
"h3": {"sizePt": 14, "weight": "bold", "color": "#2C3E50", "spaceBeforePt": 16, "spaceAfterPt": 4},
"h4": {"sizePt": 12, "weight": "bold", "color": "#2C3E50", "spaceBeforePt": 12, "spaceAfterPt": 3},
diff --git a/modules/serviceCenter/services/serviceSubscription/enterpriseRenewalScheduler.py b/modules/serviceCenter/services/serviceSubscription/enterpriseRenewalScheduler.py
new file mode 100644
index 00000000..9db20b0f
--- /dev/null
+++ b/modules/serviceCenter/services/serviceSubscription/enterpriseRenewalScheduler.py
@@ -0,0 +1,89 @@
+# Copyright (c) 2025 Patrick Motsch
+# All rights reserved.
+"""Enterprise subscription auto-renewal scheduler.
+
+Runs daily via eventManager (APScheduler). Checks all enterprise subscriptions
+with autoRenew=True whose period has ended and renews them automatically
+(old -> EXPIRED, new -> ACTIVE with same duration and params, budget credit,
+invoice email).
+"""
+
+import logging
+from datetime import datetime, timezone
+
+logger = logging.getLogger(__name__)
+
+
+async def _runEnterpriseAutoRenewal() -> None:
+ """Scheduled task: auto-renew enterprise subscriptions whose period has ended."""
+ try:
+ from modules.interfaces.interfaceDbSubscription import getRootInterface as _getSubRoot
+ from modules.datamodels.datamodelSubscription import SubscriptionStatusEnum
+
+ subIface = _getSubRoot()
+ allSubs = subIface.listAll([SubscriptionStatusEnum.ACTIVE])
+
+ nowTs = datetime.now(timezone.utc).timestamp()
+ renewed = 0
+
+ for sub in allSubs:
+ if not sub.get("isEnterprise"):
+ continue
+ if not sub.get("recurring"):
+ continue
+ periodEnd = sub.get("currentPeriodEnd")
+ if not periodEnd or periodEnd > nowTs:
+ continue
+
+ mandateId = sub["mandateId"]
+ subId = sub["id"]
+ periodStart = sub.get("currentPeriodStart") or sub.get("startedAt") or nowTs
+ periodDuration = periodEnd - periodStart
+ if periodDuration <= 0:
+ periodDuration = 30 * 86400
+ newEndDate = nowTs + periodDuration
+
+ try:
+ from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import (
+ getService as getSubscriptionService,
+ )
+ from modules.security.rootAccess import getRootUser
+ rootUser = getRootUser()
+ subService = getSubscriptionService(rootUser, mandateId)
+ subService.renewEnterprise(subId, newEndDate)
+ renewed += 1
+ logger.info(
+ "Auto-renewed enterprise subscription %s for mandate %s (new end: %s)",
+ subId, mandateId,
+ datetime.fromtimestamp(newEndDate, tz=timezone.utc).isoformat(),
+ )
+ except Exception as e:
+ logger.error(
+ "Auto-renewal failed for enterprise subscription %s mandate %s: %s",
+ subId, mandateId, e,
+ )
+
+ if renewed:
+ logger.info("Enterprise auto-renewal completed: %d subscription(s) renewed", renewed)
+
+ except Exception as e:
+ logger.error("Enterprise auto-renewal task failed: %s", e)
+
+
+def registerEnterpriseRenewalScheduler() -> None:
+ """Register the enterprise auto-renewal cron job (daily at 06:00 UTC)."""
+ try:
+ from modules.shared.eventManagement import eventManager
+
+ eventManager.registerCron(
+ jobId="enterprise_auto_renewal",
+ func=_runEnterpriseAutoRenewal,
+ cronKwargs={
+ "hour": "6",
+ "minute": "0",
+ },
+ )
+ logger.info("Enterprise auto-renewal scheduler registered (daily at 06:00 UTC)")
+
+ except Exception as e:
+ logger.error("Failed to register enterprise auto-renewal scheduler: %s", e)
diff --git a/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py b/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py
index 1a902945..0d3ae954 100644
--- a/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py
+++ b/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py
@@ -26,6 +26,7 @@ from modules.interfaces.interfaceDbSubscription import (
getInterface as getSubscriptionInterface,
InvalidTransitionError,
)
+from modules.shared.i18nRegistry import t
logger = logging.getLogger(__name__)
@@ -581,6 +582,256 @@ class SubscriptionService:
def syncStripeQuantity(self, subscriptionId: str):
self._interface.syncQuantityToStripe(subscriptionId)
+ # =========================================================================
+ # Enterprise subscription management (sysadmin-only)
+ # =========================================================================
+
+ def createEnterprise(
+ self, mandateId: str,
+ startDate: float, endDate: float, autoRenew: bool,
+ flatPriceCHF: float,
+ maxUsers: Optional[int], maxFeatureInstances: Optional[int],
+ maxDataVolumeMB: Optional[int], budgetAiCHF: Optional[float],
+ note: Optional[str] = None,
+ ) -> Dict[str, Any]:
+ """Create a new enterprise subscription with custom flat pricing and limits.
+
+ 1. Cleanup PENDING/SCHEDULED predecessors
+ 2. Expire current operative subscription (no Stripe cancel)
+ 3. Create ACTIVE MandateSubscription with enterprise fields
+ 4. Credit fixed AI budget to mandate pool
+ 5. Send invoice email to mandate admins
+ """
+ self._cleanupPreparatorySubscriptions(mandateId)
+
+ currentOperative = self._interface.getOperativeForMandate(mandateId)
+ if currentOperative:
+ self._expireOperative(currentOperative["id"], mandateId)
+
+ sub = MandateSubscription(
+ mandateId=mandateId,
+ planKey="ENTERPRISE",
+ status=SubscriptionStatusEnum.ACTIVE,
+ recurring=autoRenew,
+ startedAt=datetime.now(timezone.utc).timestamp(),
+ currentPeriodStart=startDate,
+ currentPeriodEnd=endDate,
+ isEnterprise=True,
+ enterpriseFlatPriceCHF=flatPriceCHF,
+ enterpriseMaxUsers=maxUsers,
+ enterpriseMaxFeatureInstances=maxFeatureInstances,
+ enterpriseMaxDataVolumeMB=maxDataVolumeMB,
+ enterpriseBudgetAiCHF=budgetAiCHF,
+ enterpriseNote=note,
+ )
+
+ created = self._interface.createSubscription(sub)
+ self.invalidateCache(mandateId)
+
+ self._creditEnterpriseBudget(mandateId, budgetAiCHF, "Erstaktivierung")
+ _notifyEnterpriseInvoice(mandateId, created)
+
+ logger.info("Enterprise subscription created for mandate %s: id=%s", mandateId, created["id"])
+ return created
+
+ def renewEnterprise(
+ self, subscriptionId: str, newEndDate: float,
+ overrides: Optional[Dict[str, Any]] = None,
+ ) -> Dict[str, Any]:
+ """Renew an enterprise subscription: expire old, create new with same or overridden params.
+
+ 1. Load + validate old subscription
+ 2. Expire old subscription
+ 3. Create new ACTIVE subscription (clone params, apply overrides)
+ 4. Credit AI budget
+ 5. Send invoice email
+ """
+ oldSub = self._interface.getById(subscriptionId)
+ if not oldSub:
+ raise ValueError(f"Subscription {subscriptionId} not found")
+ if not oldSub.get("isEnterprise"):
+ raise ValueError(f"Subscription {subscriptionId} is not an enterprise subscription")
+
+ mandateId = oldSub["mandateId"]
+ self._interface.forceExpire(subscriptionId)
+ self.invalidateCache(mandateId)
+
+ overrides = overrides or {}
+ nowTs = datetime.now(timezone.utc).timestamp()
+ startDate = nowTs
+ autoRenew = overrides.get("autoRenew", oldSub.get("recurring", False))
+ flatPriceCHF = overrides.get("flatPriceCHF", oldSub.get("enterpriseFlatPriceCHF"))
+ maxUsers = overrides.get("maxUsers", oldSub.get("enterpriseMaxUsers"))
+ maxFeatureInstances = overrides.get("maxFeatureInstances", oldSub.get("enterpriseMaxFeatureInstances"))
+ maxDataVolumeMB = overrides.get("maxDataVolumeMB", oldSub.get("enterpriseMaxDataVolumeMB"))
+ budgetAiCHF = overrides.get("budgetAiCHF", oldSub.get("enterpriseBudgetAiCHF"))
+ note = overrides.get("note", oldSub.get("enterpriseNote"))
+
+ sub = MandateSubscription(
+ mandateId=mandateId,
+ planKey="ENTERPRISE",
+ status=SubscriptionStatusEnum.ACTIVE,
+ recurring=autoRenew,
+ startedAt=nowTs,
+ currentPeriodStart=startDate,
+ currentPeriodEnd=newEndDate,
+ isEnterprise=True,
+ enterpriseFlatPriceCHF=flatPriceCHF,
+ enterpriseMaxUsers=maxUsers,
+ enterpriseMaxFeatureInstances=maxFeatureInstances,
+ enterpriseMaxDataVolumeMB=maxDataVolumeMB,
+ enterpriseBudgetAiCHF=budgetAiCHF,
+ enterpriseNote=note,
+ )
+
+ created = self._interface.createSubscription(sub)
+ self.invalidateCache(mandateId)
+
+ self._creditEnterpriseBudget(mandateId, budgetAiCHF, "Erneuerung")
+ _notifyEnterpriseInvoice(mandateId, created)
+
+ logger.info(
+ "Enterprise subscription renewed for mandate %s: old=%s -> new=%s",
+ mandateId, subscriptionId, created["id"],
+ )
+ return created
+
+ def updateEnterprise(self, subscriptionId: str, changes: Dict[str, Any]) -> Dict[str, Any]:
+ """Update enterprise subscription parameters (limits, note, flat price).
+
+ Only enterprise-specific fields are allowed. No status change."""
+ sub = self._interface.getById(subscriptionId)
+ if not sub:
+ raise ValueError(f"Subscription {subscriptionId} not found")
+ if not sub.get("isEnterprise"):
+ raise ValueError(f"Subscription {subscriptionId} is not an enterprise subscription")
+
+ allowedFields = {
+ "enterpriseFlatPriceCHF", "enterpriseMaxUsers", "enterpriseMaxFeatureInstances",
+ "enterpriseMaxDataVolumeMB", "enterpriseBudgetAiCHF", "enterpriseNote",
+ "recurring",
+ }
+ updateData = {k: v for k, v in changes.items() if k in allowedFields}
+ if not updateData:
+ raise ValueError("No valid enterprise fields to update")
+
+ result = self._interface.updateFields(subscriptionId, updateData)
+ self.invalidateCache(sub["mandateId"])
+ logger.info("Enterprise subscription %s updated: %s", subscriptionId, list(updateData.keys()))
+ return result
+
+ def _creditEnterpriseBudget(
+ self, mandateId: str, budgetAiCHF: Optional[float], periodLabel: str,
+ ) -> None:
+ if not budgetAiCHF or budgetAiCHF <= 0:
+ return
+ try:
+ from modules.interfaces.interfaceDbBilling import getRootInterface as _getBillingRoot
+ _getBillingRoot().creditSubscriptionBudget(
+ mandateId, "ENTERPRISE", periodLabel=periodLabel,
+ enterpriseBudgetOverride=budgetAiCHF,
+ )
+ except Exception as e:
+ logger.error("Enterprise budget credit failed for mandate %s: %s", mandateId, e)
+
+
+# ============================================================================
+# Enterprise Invoice Email
+# ============================================================================
+
+def _notifyEnterpriseInvoice(mandateId: str, subRecord: Dict[str, Any]) -> None:
+ """Send enterprise invoice email to mandate admins."""
+ try:
+ from modules.shared.notifyMandateAdmins import notifyMandateAdmins
+
+ rawHtml = _buildEnterpriseInvoiceHtml(subRecord)
+ flatPrice = subRecord.get("enterpriseFlatPriceCHF") or 0
+ notifyMandateAdmins(
+ mandateId,
+ t("[PowerOn] Enterprise-Abonnement — Rechnung") + f" (CHF {flatPrice:,.2f})",
+ t("Enterprise-Abonnement — Rechnung"),
+ [
+ t("Das Enterprise-Abonnement wurde aktiviert."),
+ t("Bitte begleichen Sie den Rechnungsbetrag innert 10 Tagen."),
+ t("Details zum Abonnement finden Sie unter Billing-Verwaltung."),
+ ],
+ rawHtmlBlock=rawHtml,
+ )
+ except Exception as e:
+ logger.error("Enterprise invoice email failed for mandate %s: %s", mandateId, e)
+
+
+def _buildEnterpriseInvoiceHtml(subRecord: Dict[str, Any]) -> str:
+ """Build HTML invoice summary for enterprise subscription email."""
+ flatPrice = subRecord.get("enterpriseFlatPriceCHF") or 0
+ maxUsers = subRecord.get("enterpriseMaxUsers")
+ maxFeatures = subRecord.get("enterpriseMaxFeatureInstances")
+ maxStorageMB = subRecord.get("enterpriseMaxDataVolumeMB")
+ budgetAi = subRecord.get("enterpriseBudgetAiCHF")
+ note = subRecord.get("enterpriseNote") or ""
+ periodStart = subRecord.get("currentPeriodStart")
+ periodEnd = subRecord.get("currentPeriodEnd")
+
+ def _chf(amount: float) -> str:
+ return f"CHF {amount:,.2f}".replace(",", "'")
+
+ def _fmtDate(ts: Optional[float]) -> str:
+ if not ts:
+ return "—"
+ from datetime import datetime, timezone
+ return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%d.%m.%Y")
+
+ detailRows = ""
+ if maxUsers is not None:
+ detailRows += (
+ f'| {t("Benutzer")} | '
+ f'max. {maxUsers} |
'
+ )
+ if maxFeatures is not None:
+ detailRows += (
+ f'| {t("Module")} | '
+ f'max. {maxFeatures} |
'
+ )
+ if maxStorageMB is not None:
+ storageLabel = f"{maxStorageMB} MB" if maxStorageMB < 1024 else f"{maxStorageMB / 1024:.1f} GB"
+ detailRows += (
+ f'| {t("Datenvolumen")} | '
+ f'max. {storageLabel} |
'
+ )
+ if budgetAi is not None and budgetAi > 0:
+ detailRows += (
+ f'| {t("KI-Budget")} | '
+ f'{_chf(budgetAi)} |
'
+ )
+
+ noteHtml = ""
+ if note:
+ import html as htmlmod
+ noteHtml = (
+ f''
+ f'{t("Notiz")}: {htmlmod.escape(note)}
'
+ )
+
+ return (
+ f''
+ f''
+ f''
+ f'| {t("Zeitraum")} | '
+ f'{_fmtDate(periodStart)} – {_fmtDate(periodEnd)} | '
+ f'
'
+ f'{detailRows}'
+ f''
+ f'| {t("Pauschale")} | '
+ f''
+ f'{_chf(flatPrice)} | '
+ f'
'
+ f''
+ f'
'
+ f''
+ f'{t("Zahlungsfrist")}: {t("10 Tage")}
'
+ f'{noteHtml}'
+ )
+
# ============================================================================
# Notifications
@@ -608,66 +859,66 @@ def _notifySubscriptionChange(
templates: Dict[str, Dict[str, Any]] = {
"activated": {
- "subject": f"[PowerOn] Abonnement aktiviert — {planLabel}",
- "headline": "Abonnement aktiviert",
+ "subject": f"[PowerOn] {t('Abonnement aktiviert')} — {planLabel}",
+ "headline": t("Abonnement aktiviert"),
"paragraphs": [
p for p in [
- f"Das Abonnement wurde auf den Plan «{planLabel}» aktiviert.",
+ t("Das Abonnement wurde auf den Plan «{planLabel}» aktiviert.").format(planLabel=planLabel),
platformHint,
- "Sie können Ihr Abonnement jederzeit unter Billing-Verwaltung › Abonnement einsehen und verwalten.",
+ t("Sie können Ihr Abonnement jederzeit unter Billing-Verwaltung › Abonnement einsehen und verwalten."),
] if p
],
},
"cancelled": {
- "subject": f"[PowerOn] Abonnement gekündigt — {planLabel}",
- "headline": "Abonnement gekündigt",
+ "subject": f"[PowerOn] {t('Abonnement gekündigt')} — {planLabel}",
+ "headline": t("Abonnement gekündigt"),
"paragraphs": [
p for p in [
- f"Das Abonnement «{planLabel}» wurde gekündigt.",
+ t("Das Abonnement «{planLabel}» wurde gekündigt.").format(planLabel=planLabel),
platformHint,
- "Die Kündigung wird zum Ende der aktuellen bezahlten Periode wirksam. Bis dahin bleibt der volle Zugang bestehen.",
+ t("Die Kündigung wird zum Ende der aktuellen bezahlten Periode wirksam. Bis dahin bleibt der volle Zugang bestehen."),
] if p
],
},
"force_cancelled": {
- "subject": f"[PowerOn] Abonnement sofort beendet — {planLabel}",
- "headline": "Abonnement sofort beendet",
+ "subject": f"[PowerOn] {t('Abonnement sofort beendet')} — {planLabel}",
+ "headline": t("Abonnement sofort beendet"),
"paragraphs": [
p for p in [
- f"Das Abonnement «{planLabel}» wurde durch den Plattform-Administrator sofort beendet.",
+ t("Das Abonnement «{planLabel}» wurde durch den Plattform-Administrator sofort beendet.").format(planLabel=planLabel),
platformHint,
- "Der Zugang wurde per sofort deaktiviert. Bei Fragen wenden Sie sich an den Plattform-Support.",
+ t("Der Zugang wurde per sofort deaktiviert. Bei Fragen wenden Sie sich an den Plattform-Support."),
] if p
],
},
"trial_expired": {
- "subject": "[PowerOn] Testphase abgelaufen",
- "headline": "Testphase abgelaufen",
+ "subject": f"[PowerOn] {t('Testphase abgelaufen')}",
+ "headline": t("Testphase abgelaufen"),
"paragraphs": [
p for p in [
- "Die kostenlose Testphase ist abgelaufen.",
+ t("Die kostenlose Testphase ist abgelaufen."),
platformHint,
- "Bitte wählen Sie einen Plan unter Billing-Verwaltung › Abonnement, damit der Zugang nicht unterbrochen wird.",
+ t("Bitte wählen Sie einen Plan unter Billing-Verwaltung › Abonnement, damit der Zugang nicht unterbrochen wird."),
] if p
],
},
"payment_failed": {
- "subject": f"[PowerOn] Zahlung fehlgeschlagen — {planLabel}",
- "headline": "Zahlung fehlgeschlagen",
+ "subject": f"[PowerOn] {t('Zahlung fehlgeschlagen')} — {planLabel}",
+ "headline": t("Zahlung fehlgeschlagen"),
"paragraphs": [
p for p in [
- f"Die Zahlung für das Abonnement «{planLabel}» ist fehlgeschlagen.",
+ t("Die Zahlung für das Abonnement «{planLabel}» ist fehlgeschlagen.").format(planLabel=planLabel),
platformHint,
- "Bitte aktualisieren Sie Ihr Zahlungsmittel unter Billing-Verwaltung.",
+ t("Bitte aktualisieren Sie Ihr Zahlungsmittel unter Billing-Verwaltung."),
] if p
],
},
}
tpl = templates.get(event, {
- "subject": f"[PowerOn] Abonnement-Änderung — {planLabel}",
- "headline": "Abonnement-Änderung",
- "paragraphs": [f"Änderung am Abonnement «{planLabel}»."],
+ "subject": f"[PowerOn] {t('Abonnement-Änderung')} — {planLabel}",
+ "headline": t("Abonnement-Änderung"),
+ "paragraphs": [t("Änderung am Abonnement «{planLabel}».").format(planLabel=planLabel)],
})
notifyMandateAdmins(
@@ -699,7 +950,7 @@ def _buildInvoiceSummaryHtml(
instanceTotal = billableModules * instancePrice
netTotal = userTotal + instanceTotal
- periodLabel = {"MONTHLY": "Monatlich", "YEARLY": "Jährlich"}.get(plan.billingPeriod, plan.billingPeriod)
+ periodLabel = {"MONTHLY": t("Monatlich"), "YEARLY": t("Jährlich")}.get(plan.billingPeriod, plan.billingPeriod)
def _chf(amount: float) -> str:
return f"CHF {amount:,.2f}".replace(",", "'")
@@ -707,13 +958,13 @@ def _buildInvoiceSummaryHtml(
rows = ""
if userPrice > 0:
rows += (
- f'| Benutzer-Lizenzen | '
+ f'
| {t("Benutzer-Lizenzen")} | '
f'{userCount} × {_chf(userPrice)} | '
f'{_chf(userTotal)} |
\n'
)
if instancePrice > 0 and billableModules > 0:
rows += (
- f'| Module ({instanceCount} total, {plan.includedModules} inkl.) | '
+ f'
| {t("Module")} ({instanceCount} total, {plan.includedModules} {t("inkl.")}) | '
f'{billableModules} × {_chf(instancePrice)} | '
f'{_chf(instanceTotal)} |
\n'
)
@@ -733,7 +984,7 @@ def _buildInvoiceSummaryHtml(
invoiceLink = (
f''
f''
- f'Vollständige Rechnung mit MwSt-Ausweis anzeigen
\n'
+ f'{t("Vollständige Rechnung mit MwSt-Ausweis anzeigen")}\n'
)
except Exception as e:
logger.warning("Could not fetch Stripe invoice URL for sub %s: %s", stripeSubId, e)
@@ -741,13 +992,13 @@ def _buildInvoiceSummaryHtml(
return (
f''
f''
- f'| Position | '
- f'Menge × Preis | '
- f'Total | '
+ f'{t("Position")} | '
+ f'{t("Menge")} × {t("Preis")} | '
+ f'{t("Total")} | '
f'
'
f'{rows}'
f''
- f'| Netto-Total ({periodLabel}) | '
+ f'{t("Netto-Total")} ({periodLabel}) | '
f' | '
f'{_chf(netTotal)} | '
f'
'
@@ -776,7 +1027,7 @@ def _buildCancelSummaryHtml(subRecord: Dict[str, Any], platformUrl: str = "") ->
parts.append(
f''
f''
- f'Letzte Stripe-Rechnung anzeigen
'
+ f'{t("Letzte Stripe-Rechnung anzeigen")}'
)
except Exception as e:
logger.warning("Could not fetch Stripe invoice URL for sub %s: %s", stripeSubId, e)
@@ -822,7 +1073,7 @@ class SubscriptionInactiveException(Exception):
self.mandateId = mandateId
self.reason = _subscriptionReasonForStatus(status)
self.userAction = _subscriptionUserActionForStatus(status)
- self.message = message or (
+ self.message = message or t(
"Kein aktives Abonnement für diesen Mandanten. Bitte wählen Sie einen Plan unter Billing."
)
super().__init__(self.message)
@@ -837,47 +1088,62 @@ class SubscriptionInactiveException(Exception):
return out
-_SUBSCRIPTION_LIMITS_UI_HINT_DE = (
- " Details zu Ihrem Abonnement, den enthaltenen Limits und Upgrade-Optionen: "
- "Menü «Administration» → «Billing» → Registerkarte «Abonnement»."
-)
+SUBSCRIPTION_USER_ACTION_CONTACT_ADMIN = "CONTACT_ADMIN"
+
+
+def _subscriptionLimitsHint() -> str:
+ return " " + t(
+ "Details zu Ihrem Abonnement, den enthaltenen Limits und Upgrade-Optionen: "
+ "Menü «Administration» → «Billing» → Registerkarte «Abonnement»."
+ )
+
+
+def _enterpriseLimitsHint() -> str:
+ return " " + t(
+ "Ihr Enterprise-Abonnement wird vom Plattform-Administrator verwaltet. "
+ "Bitte kontaktieren Sie den Administrator für eine Anpassung der Limiten."
+ )
class SubscriptionCapacityException(Exception):
- def __init__(self, resourceType: str, currentCount: int, maxAllowed: int, message: Optional[str] = None):
+ def __init__(self, resourceType: str, currentCount: int, maxAllowed: int,
+ message: Optional[str] = None, isEnterprise: bool = False):
self.resourceType = resourceType
self.currentCount = currentCount
self.maxAllowed = maxAllowed
+ self.isEnterprise = isEnterprise
+ hint = _enterpriseLimitsHint() if isEnterprise else _subscriptionLimitsHint()
if message is not None:
self.message = message
elif resourceType == "users":
- self.message = (
- f"Mit dem aktuellen Abonnement sind für diesen Mandanten höchstens {maxAllowed} "
- f"Benutzer zulässig (derzeit {currentCount}). "
- f"Ohne Planwechsel können keine weiteren Benutzer hinzugefügt werden."
- ) + _SUBSCRIPTION_LIMITS_UI_HINT_DE
+ self.message = t(
+ "Mit dem aktuellen Abonnement sind für diesen Mandanten höchstens {maxAllowed} "
+ "Benutzer zulässig (derzeit {currentCount}). "
+ "Ohne Planwechsel können keine weiteren Benutzer hinzugefügt werden."
+ ).format(maxAllowed=maxAllowed, currentCount=currentCount) + hint
elif resourceType == "featureInstances":
- self.message = (
- f"Es sind höchstens {maxAllowed} aktive Module erlaubt (derzeit {currentCount}). "
- f"Bitte Abonnement erweitern oder ein Modul entfernen."
- ) + _SUBSCRIPTION_LIMITS_UI_HINT_DE
+ self.message = t(
+ "Es sind höchstens {maxAllowed} aktive Module erlaubt (derzeit {currentCount}). "
+ "Bitte Abonnement erweitern oder ein Modul entfernen."
+ ).format(maxAllowed=maxAllowed, currentCount=currentCount) + hint
elif resourceType == "dataVolumeMB":
- self.message = (
- f"Das im Abonnement enthaltene Datenvolumen ({maxAllowed} MB) reicht nicht "
- f"(aktuell ca. {currentCount} MB). Bitte Speicher-Limit oder Plan anpassen."
- ) + _SUBSCRIPTION_LIMITS_UI_HINT_DE
+ self.message = t(
+ "Das im Abonnement enthaltene Datenvolumen ({maxAllowed} MB) reicht nicht "
+ "(aktuell ca. {currentCount} MB). Bitte Speicher-Limit oder Plan anpassen."
+ ).format(maxAllowed=maxAllowed, currentCount=currentCount) + hint
else:
- self.message = (
- f"Abonnement-Limit überschritten (Ressource «{resourceType}»: "
- f"aktuell {currentCount}, erlaubt {maxAllowed})."
- ) + _SUBSCRIPTION_LIMITS_UI_HINT_DE
+ self.message = t(
+ "Abonnement-Limit überschritten (Ressource «{resourceType}»: "
+ "aktuell {currentCount}, erlaubt {maxAllowed})."
+ ).format(resourceType=resourceType, currentCount=currentCount, maxAllowed=maxAllowed) + hint
super().__init__(self.message)
def toClientDict(self) -> Dict[str, Any]:
+ action = SUBSCRIPTION_USER_ACTION_CONTACT_ADMIN if self.isEnterprise else SUBSCRIPTION_USER_ACTION_UPGRADE
return {
"error": f"SUBSCRIPTION_{self.resourceType.upper()}_LIMIT",
"currentCount": self.currentCount, "maxAllowed": self.maxAllowed,
- "message": self.message, "userAction": SUBSCRIPTION_USER_ACTION_UPGRADE,
+ "message": self.message, "userAction": action,
"subscriptionUiPath": "/admin/billing?tab=subscription",
}
diff --git a/tests/serviceGeneration/test_style_resolver.py b/tests/serviceGeneration/test_style_resolver.py
index 6b2b649a..06f907ef 100644
--- a/tests/serviceGeneration/test_style_resolver.py
+++ b/tests/serviceGeneration/test_style_resolver.py
@@ -37,3 +37,10 @@ def test_full_style_passthrough():
result = resolveStyle(custom)
assert result["fonts"]["primary"] == "Helvetica"
assert result["fonts"]["monospace"] == "Monaco"
+
+
+def test_override_document_title_partial_merge():
+ result = resolveStyle({"documentTitle": {"sizePt": 32}})
+ assert result["documentTitle"]["sizePt"] == 32
+ assert result["documentTitle"]["align"] == "center"
+ assert result["headings"]["h1"]["sizePt"] == DEFAULT_STYLE["headings"]["h1"]["sizePt"]