From bf261d656629fd1ecee6d650321dbdfdd9752d66 Mon Sep 17 00:00:00 2001 From: Ida Date: Thu, 28 May 2026 15:54:41 +0200 Subject: [PATCH] feat: billing admin link in exhaust notification email + requestFrontendUrl middleware --- app.py | 2 + env-dev.env | 5 ++ env-int.env | 1 + env-prod.env | 1 + .../serviceBilling/billingExhaustedNotify.py | 7 +- modules/shared/notifyMandateAdmins.py | 76 ++++++++++++++++++- 6 files changed, 90 insertions(+), 2 deletions(-) diff --git a/app.py b/app.py index a69e9a7e..ac390317 100644 --- a/app.py +++ b/app.py @@ -564,6 +564,7 @@ from modules.auth import ( # without having to thread them through every call site. from modules.shared.i18nRegistry import setLanguage, normalizePrimaryLanguageTag from modules.shared.timeUtils import setRequestTimezone +from modules.shared.requestFrontendUrl import resolveFrontendUrlFromRequest, setRequestFrontendUrl @app.middleware("http") async def _requestContextMiddleware(request: Request, call_next): @@ -571,6 +572,7 @@ async def _requestContextMiddleware(request: Request, call_next): lang = normalizePrimaryLanguageTag(acceptLang, "de") setLanguage(lang) setRequestTimezone(request.headers.get("X-User-Timezone", "")) + setRequestFrontendUrl(resolveFrontendUrlFromRequest(request)) return await call_next(request) app.add_middleware(CSRFMiddleware) diff --git a/env-dev.env b/env-dev.env index 467f70b4..d8a1e9d5 100644 --- a/env-dev.env +++ b/env-dev.env @@ -4,7 +4,12 @@ APP_ENV_TYPE = dev APP_ENV_LABEL = Development Instance Patrick APP_API_URL = http://localhost:8000 +<<<<<<< Updated upstream APP_KEY_SYSVAR = D:/Athi/Local/Web/poweron-swiss/local/notes/key.txt +======= +APP_FRONTEND_URL = http://localhost:5176 +APP_KEY_SYSVAR = D:/Athi/Local/Web/poweron/local/notes/key.txt +>>>>>>> Stashed changes APP_INIT_PASS_ADMIN_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEeFFtRGtQeVUtcjlrU3dab1ZxUm9WSks0MlJVYUtERFlqUElHemZrOGNENk1tcmJNX3Vxc01UMDhlNU40VzZZRVBpUGNmT3podzZrOGhOeEJIUEt4eVlSWG5UYXA3d09DVXlLT21Kb1JYSUU9 APP_INIT_PASS_EVENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERzZjNm56WGVBdjJTeG5Udjd6OGQwUVotYXUzQjJ1YVNyVXVBa3NZVml3ODU0MVNkZjhWWmJwNUFkc19BcHlHMTU1Q3BRcHU0cDBoZkFlR2l6UEZQU3d2U3MtMDh5UDZteGFoQ0EyMUE1ckE9 diff --git a/env-int.env b/env-int.env index 72099314..1ce618f9 100644 --- a/env-int.env +++ b/env-int.env @@ -4,6 +4,7 @@ APP_ENV_TYPE = int APP_ENV_LABEL = Integration Instance APP_API_URL = https://api-int.poweron.swiss +APP_FRONTEND_URL = https://nyla-int.poweron.swiss # Force SameSite=None+Secure for auth cookies (cross-site UI on poweron-center.net). Optional if APP_API_URL is https:// APP_COOKIE_SECURE = true APP_KEY_SYSVAR = /srv/gateway/shared/secrets/master_key.txt diff --git a/env-prod.env b/env-prod.env index d268450c..6f97cee8 100644 --- a/env-prod.env +++ b/env-prod.env @@ -7,6 +7,7 @@ APP_KEY_SYSVAR = /srv/gateway/shared/secrets/master_key.txt APP_INIT_PASS_ADMIN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3UnJRV0sySFlDblpXUlREclREaW1WbUt6bGtQYkdrNkZDOXNOLXFua1hqeFF2RHJnRXJ5VlVGV3hOZm41QjZOMlNTb0duYXNxZi05dXVTc2xDVkx0SVBFLUhncVo5T0VUZHE0UTZLWWw3ck09 APP_INIT_PASS_EVENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3QVpIY19DQVZSSzJmc2F0VEZvQlU1cHBhTEgxdHdnR3g4eW01aTEzYTUxc1gxTDR1RVVpSHRXYjV6N1BLZUdCUGlfOW1qdy0xSHFVRkNBcGZvaGlSSkZycXRuUllaWnpyVGRoeFg1dGEyNUk9 APP_API_URL = https://api.poweron.swiss +APP_FRONTEND_URL = https://nyla.poweron.swiss # PostgreSQL DB Host (porta-main-db on Infomaniak Public Cloud) DB_HOST=db.poweron.swiss diff --git a/modules/serviceCenter/services/serviceBilling/billingExhaustedNotify.py b/modules/serviceCenter/services/serviceBilling/billingExhaustedNotify.py index d8f2acc4..b5ab9e53 100644 --- a/modules/serviceCenter/services/serviceBilling/billingExhaustedNotify.py +++ b/modules/serviceCenter/services/serviceBilling/billingExhaustedNotify.py @@ -11,7 +11,7 @@ from __future__ import annotations import logging import time -from typing import Dict +from typing import Dict, Optional logger = logging.getLogger(__name__) @@ -27,6 +27,7 @@ def maybeEmailMandatePoolExhausted( currentBalance: float, requiredAmount: float, cooldownSec: float = _DEFAULT_COOLDOWN_SEC, + frontendUrl: Optional[str] = None, ) -> None: """ Send one notification per mandate per cooldown window when the pool is exhausted. @@ -38,6 +39,7 @@ def maybeEmailMandatePoolExhausted( currentBalance: Pool balance (CHF). requiredAmount: Minimum required (CHF). cooldownSec: Minimum seconds between emails for this mandate. + frontendUrl: Optional frontend base URL for /billing/admin link (defaults to request Origin). """ if not mandateId: return @@ -67,6 +69,9 @@ def maybeEmailMandatePoolExhausted( "Bitte laden Sie das Mandats-Guthaben in der Billing-Verwaltung auf, " "damit Benutzer wieder AI-Funktionen nutzen können.", ], + includeBillingAdminLink=True, + billingAdminButtonText="Guthaben aufladen", + frontendUrl=frontendUrl, ) if sent > 0: _poolExhaustedEmailLastSent[mandateId] = now diff --git a/modules/shared/notifyMandateAdmins.py b/modules/shared/notifyMandateAdmins.py index 6ac6fa53..081ebdda 100644 --- a/modules/shared/notifyMandateAdmins.py +++ b/modules/shared/notifyMandateAdmins.py @@ -128,6 +128,57 @@ def resolveMandateName(mandateId: str) -> str: # ============================================================================ +def buildBillingAdminUrl(frontendUrl: str) -> str: + """Absolute URL to /billing/admin for the given frontend base (no trailing slash on base).""" + base = (frontendUrl or "").strip().rstrip("/") + if not base: + return "" + return f"{base}/billing/admin" + + +def buildActionLinkHtmlBlock(url: str, buttonText: str) -> str: + """Render a CTA button plus the same URL as a plain link (auth-email style).""" + if not url or not buttonText: + return "" + escaped_url = html.escape(url) + escaped_label = html.escape(buttonText) + return f'''
+ + {escaped_label} + +
+

+ {escaped_url} +

''' + + +def buildBillingAdminActionBlock( + frontendUrl: str, + buttonText: str = "Billing-Verwaltung öffnen", +) -> str: + """CTA block linking to /billing/admin (auth-email style button + plain link).""" + admin_url = buildBillingAdminUrl(frontendUrl) + if not admin_url: + return "" + return buildActionLinkHtmlBlock(admin_url, buttonText) + + +def _resolveBillingAdminFrontendUrl(frontendUrl: Optional[str]) -> str: + """Explicit frontendUrl param, else per-request value from middleware.""" + explicit = (frontendUrl or "").strip().rstrip("/") + if explicit: + return explicit + try: + from modules.shared.requestFrontendUrl import getRequestFrontendUrl + + return getRequestFrontendUrl() + except Exception: + return "" + + def _getOperatorInfo() -> Dict[str, str]: """Load operator company data from config.ini.""" try: @@ -147,11 +198,13 @@ def renderHtmlEmail( mandateName: str, footerNote: Optional[str] = None, rawHtmlBlock: Optional[str] = None, + actionHtmlBlock: Optional[str] = None, ) -> str: """Render a clean, professional HTML notification email. Args: rawHtmlBlock: Optional pre-formatted HTML inserted after bodyParagraphs (e.g. invoice table). + actionHtmlBlock: Optional CTA block (e.g. button + link to billing admin). """ hl = html.escape(headline) mn = html.escape(mandateName) @@ -165,6 +218,10 @@ def renderHtmlEmail( if rawHtmlBlock: rawBlock = f'
{rawHtmlBlock}
\n' + actionBlock = "" + if actionHtmlBlock: + actionBlock = f'
{actionHtmlBlock}
\n' + footer = "" if footerNote: footer = ( @@ -199,6 +256,7 @@ def renderHtmlEmail(
{paragraphsHtml} {rawBlock} + {actionBlock}
{footer} @@ -229,6 +287,9 @@ def notifyMandateAdmins( *, footerNote: Optional[str] = None, rawHtmlBlock: Optional[str] = None, + includeBillingAdminLink: bool = False, + billingAdminButtonText: str = "Billing-Verwaltung öffnen", + frontendUrl: Optional[str] = None, ) -> int: """ Send a styled HTML notification to all mandate admins. @@ -240,6 +301,9 @@ def notifyMandateAdmins( bodyParagraphs: List of paragraph strings (plain text, auto-escaped). footerNote: Optional small-print note below the main content. rawHtmlBlock: Optional pre-formatted HTML block (e.g. invoice summary table). + includeBillingAdminLink: When True, append button + link to {frontendUrl}/billing/admin. + billingAdminButtonText: Label for the billing admin CTA button. + frontendUrl: Frontend base URL (like auth emails); defaults to per-request Origin / X-Frontend-Url. Returns: Number of recipients that were successfully notified. @@ -257,7 +321,17 @@ def notifyMandateAdmins( return 0 mandateName = resolveMandateName(mandateId) - htmlMessage = renderHtmlEmail(headline, bodyParagraphs, mandateName, footerNote, rawHtmlBlock) + actionHtmlBlock = None + if includeBillingAdminLink: + resolved_frontend = _resolveBillingAdminFrontendUrl(frontendUrl) + actionHtmlBlock = buildBillingAdminActionBlock(resolved_frontend, billingAdminButtonText) or None + if not actionHtmlBlock: + logger.warning( + "notifyMandateAdmins: billing admin link omitted (no frontendUrl on request)" + ) + htmlMessage = renderHtmlEmail( + headline, bodyParagraphs, mandateName, footerNote, rawHtmlBlock, actionHtmlBlock, + ) messaging = getMessagingInterface() successCount = 0