hotfix msft/google login tokens end to end separated from connection

feat(billing): Nutzerhinweise bei leerem Budget + Mandats-Mail (402/SSE)
Gateway
- InsufficientBalanceException: billingModel, userAction (TOP_UP_SELF /
  CONTACT_MANDATE_ADMIN), DE/EN-Texte, toClientDict(), fromBalanceCheck()
- HTTP 402 + JSON detail für globale API-Fehlerbehandlung
- AI/Chatbot: vor Raise ggf. E-Mail an BillingSettings.notifyEmails
  (PREPAY_MANDATE, Throttle 1h/Mandat) via billingExhaustedNotify
- Agent-Loop & Workspace-Route: SSE-ERROR mit strukturiertem Billing-Payload
- datamodelBilling: notifyEmails-Doku für Pool-Alerts
frontend_nyla
- useWorkspace: SSE onError für INSUFFICIENT_BALANCE mit messageDe/En
  und Hinweis auf Billing-Pfad bei TOP_UP_SELF
This commit is contained in:
ValueOn AG 2026-03-21 01:34:40 +01:00
parent eefb37050e
commit 0a0973d41b
32 changed files with 2179 additions and 1539 deletions

16
app.py
View file

@ -8,7 +8,8 @@ from urllib.parse import quote_plus
os.environ["NUMEXPR_MAX_THREADS"] = "12" os.environ["NUMEXPR_MAX_THREADS"] = "12"
from fastapi import FastAPI from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import HTTPBearer from fastapi.security import HTTPBearer
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@ -493,6 +494,19 @@ from slowapi import _rate_limit_exceeded_handler
app.state.limiter = limiter app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
async def _insufficientBalanceHandler(request: Request, exc: Exception):
"""HTTP 402 with structured billing hint (PREPAY_USER vs PREPAY_MANDATE)."""
payload = exc.toClientDict() if hasattr(exc, "toClientDict") else {"error": "INSUFFICIENT_BALANCE", "message": str(exc)}
return JSONResponse(status_code=402, content={"detail": payload})
from modules.serviceCenter.services.serviceBilling.mainServiceBilling import (
InsufficientBalanceException,
)
app.add_exception_handler(InsufficientBalanceException, _insufficientBalanceHandler)
# CSRF protection middleware # CSRF protection middleware
from modules.auth import CSRFMiddleware from modules.auth import CSRFMiddleware
from modules.auth import ( from modules.auth import (

View file

@ -31,9 +31,20 @@ APP_LOGGING_FILE_ENABLED = True
APP_LOGGING_ROTATION_SIZE = 10485760 APP_LOGGING_ROTATION_SIZE = 10485760
APP_LOGGING_BACKUP_COUNT = 5 APP_LOGGING_BACKUP_COUNT = 5
# Service Redirects # OAuth: Auth app (login/JWT) vs Data app (Microsoft Graph / Google APIs). Same IDs until you split apps in Azure / GCP.
Service_MSFT_REDIRECT_URI = http://localhost:8000/api/msft/auth/callback Service_MSFT_AUTH_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_GOOGLE_REDIRECT_URI = http://localhost:8000/api/google/auth/callback Service_MSFT_AUTH_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm83T29rV1pQelMtc1p1MXR4NTFpa19CTEhHQ0xfNmdPUmZqcWp5UHBMS0hYTGl4c1pPdmhTNTJVWUl5WnlnUUZhV0VTRzVCb0d5YjR1NnZPZk5CZ0dGazNGdUJVbjkxeVdrYlNiVjJUYzF2aVFtQnVxTHFqTTJqZlF0RTFGNmE1OGN1TEk=
Service_MSFT_AUTH_REDIRECT_URI = http://localhost:8000/api/msft/auth/login/callback
Service_MSFT_DATA_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_MSFT_DATA_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm83T29rV1pQelMtc1p1MXR4NTFpa19CTEhHQ0xfNmdPUmZqcWp5UHBMS0hYTGl4c1pPdmhTNTJVWUl5WnlnUUZhV0VTRzVCb0d5YjR1NnZPZk5CZ0dGazNGdUJVbjkxeVdrYlNiVjJUYzF2aVFtQnVxTHFqTTJqZlF0RTFGNmE1OGN1TEk=
Service_MSFT_DATA_REDIRECT_URI = http://localhost:8000/api/msft/auth/connect/callback
Service_GOOGLE_AUTH_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_AUTH_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETDJhbGVQMHlFQzNPVFI1ZzBMa3pNMGlQUHhaQm10eVl1bFlSeTBybzlTOWE2MURXQ0hkRlo0NlNGbHQxWEl1OVkxQnVKYlhhOXR1cUF4T3k0WDdscktkY1oyYllRTmdDTWpfbUdwWGtSd1JvNlYxeTBJdEtaaS1vYnItcW0yaFM=
Service_GOOGLE_AUTH_REDIRECT_URI = http://localhost:8000/api/google/auth/login/callback
Service_GOOGLE_DATA_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_DATA_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETDJhbGVQMHlFQzNPVFI1ZzBMa3pNMGlQUHhaQm10eVl1bFlSeTBybzlTOWE2MURXQ0hkRlo0NlNGbHQxWEl1OVkxQnVKYlhhOXR1cUF4T3k0WDdscktkY1oyYllRTmdDTWpfbUdwWGtSd1JvNlYxeTBJdEtaaS1vYnItcW0yaFM=
Service_GOOGLE_DATA_REDIRECT_URI = http://localhost:8000/api/google/auth/connect/callback
# Stripe Billing (both end with _SECRET for encryption script) # Stripe Billing (both end with _SECRET for encryption script)
STRIPE_SECRET_KEY_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGWDkxSldfM0NCZ3dmbHY5cS1nQlI3UWZ4ZWRrNVdUdEFKa25RckRiQWY0c1E5MjVsZzlfRkZEU0VFU2tNQ01qZnRNQ0pZVU9hVFN6OEU0RXhwdTl3algzLWJlSXRhYmZlMHltSC1XejlGWEU5TDF1LUlYNEh1aG9tRFI4YmlCYzUyei02U1dabWoyb0N2dVFSb1RhWTNnQjBCZkFjV0FfOWdYdDVpX1k5R2pYM1R6SHRiaE10V1l1dnQybjVHWDRiQUJLM0UxRDZnczhJZGFsc3JhOU82QT09 STRIPE_SECRET_KEY_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGWDkxSldfM0NCZ3dmbHY5cS1nQlI3UWZ4ZWRrNVdUdEFKa25RckRiQWY0c1E5MjVsZzlfRkZEU0VFU2tNQ01qZnRNQ0pZVU9hVFN6OEU0RXhwdTl3algzLWJlSXRhYmZlMHltSC1XejlGWEU5TDF1LUlYNEh1aG9tRFI4YmlCYzUyei02U1dabWoyb0N2dVFSb1RhWTNnQjBCZkFjV0FfOWdYdDVpX1k5R2pYM1R6SHRiaE10V1l1dnQybjVHWDRiQUJLM0UxRDZnczhJZGFsc3JhOU82QT09
@ -48,15 +59,8 @@ Connector_AiTavily_API_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEQTdnUHMwd2pIaXNtMmtCTFRE
Connector_AiPrivateLlm_API_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGRHM5eFdUVmVZU1R1cHBwN1RlMUx4T0NlLTJLUFFVX3J2OElDWFpuZmJHVmp4Z3BNNWMwZUVVZUd2TFhRSjVmVkVlcFlVRWtybXh0ZHloZ01ZcnVvX195YjdlWVdEcjZSWFFTTlNBWUlaTlNoLWhqVFBIb0thVlBiaWhjYjFQOFY= Connector_AiPrivateLlm_API_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGRHM5eFdUVmVZU1R1cHBwN1RlMUx4T0NlLTJLUFFVX3J2OElDWFpuZmJHVmp4Z3BNNWMwZUVVZUd2TFhRSjVmVkVlcFlVRWtybXh0ZHloZ01ZcnVvX195YjdlWVdEcjZSWFFTTlNBWUlaTlNoLWhqVFBIb0thVlBiaWhjYjFQOFY=
Connector_AiMistral_API_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGeEQxYUIxOHhia0JlQWpWQ2dWQWZzY3l6SWwyUnJoR1hRQWloX2lxb2lGNkc4UnA4U2tWNjJaYzB1d1hvNG9fWUp1N3V4OW9FMGhaWVhjSlVwWEc1X2loVDBSZDEtdHdfcTA5QkcxQTR4OHc4RkRzclJrU2d1RFZpNDJkRDRURlE= Connector_AiMistral_API_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGeEQxYUIxOHhia0JlQWpWQ2dWQWZzY3l6SWwyUnJoR1hRQWloX2lxb2lGNkc4UnA4U2tWNjJaYzB1d1hvNG9fWUp1N3V4OW9FMGhaWVhjSlVwWEc1X2loVDBSZDEtdHdfcTA5QkcxQTR4OHc4RkRzclJrU2d1RFZpNDJkRDRURlE=
# Microsoft Service Configuration
Service_MSFT_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_MSFT_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm83T29rV1pQelMtc1p1MXR4NTFpa19CTEhHQ0xfNmdPUmZqcWp5UHBMS0hYTGl4c1pPdmhTNTJVWUl5WnlnUUZhV0VTRzVCb0d5YjR1NnZPZk5CZ0dGazNGdUJVbjkxeVdrYlNiVjJUYzF2aVFtQnVxTHFqTTJqZlF0RTFGNmE1OGN1TEk=
Service_MSFT_TENANT_ID = common Service_MSFT_TENANT_ID = common
# Google Service configuration
Service_GOOGLE_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETDJhbGVQMHlFQzNPVFI1ZzBMa3pNMGlQUHhaQm10eVl1bFlSeTBybzlTOWE2MURXQ0hkRlo0NlNGbHQxWEl1OVkxQnVKYlhhOXR1cUF4T3k0WDdscktkY1oyYllRTmdDTWpfbUdwWGtSd1JvNlYxeTBJdEtaaS1vYnItcW0yaFM=
# Google Cloud Speech Services configuration # Google Cloud Speech Services configuration
Connector_GoogleSpeech_API_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETk5FWWM3Q0JKMzhIYTlyMkhuNjA4NlF4dk82U2NScHhTVGY3UG83NkhfX3RrcWVtWWcyLXRjU1dTT21zWEl6YWRMMUFndXpsUnJOeHh3QThsNDZKRXROTzdXRUdsT0JZajZJNVlfb0gtMXkwWm9DOERPVnpjU0pyUEZfOGJsUnprT3ltMVVhalUyUm9hMUFtZEtHUnJqOGZ4dEZjZm5SWVVTckVCWnY1UkdVSHVmUlgwbnAyc0xDQW84R3ViSko5OHVCVWZRUVNiaG1pVFB6X3EwS0FPd2dUYjhiSmRjcXh2WEZiXzI4SFZqT21tbDduUWRyVWdFZXpmcVM5ZDR0VWtzZnF5UER6cGwwS2JlLV9CSTZ0Z0IyQ1h0YW9TcmhRTXZEckp4bWhmTkt6UTNYMk4zVkpnbUJmaDIxZnoyR2dWTEYwTUFEV0w2eUdUUGpoZk9XRkt4RVF1Z1NPdUpBeTcyWV9PY1Ffd2s0ZEdVekxGekhoeEl4TmNqaXYtbUJuSVdycFducERWdWtZajZnX011Q2w4eE9VMTBqQ1ZxRmdScWhXY1E3WWhzX1JZcHhxam9FbDVPN3Q1MWtrMUZuTUg3LVFQVHp1T1hpQWNDMzEzekVJWk9ybl91YUVjSkFob1VaMi1ONEtuMnRSOEg1S3QybUMwbVZDejItajBLTjM2Zy1hNzZQMW5LLVVDVGdFWm5BZUxNeEFnUkZzU3dxV0lCUlc0LWo4b05GczVpOGZSV2ZxbFBwUml6OU5tYjdnTks3Y3hrVEZVTHlmc1NPdFh4WE5pWldEZklOQUxBbjBpMTlkX3FFQVJ6c2NSZGdzTThycE92VW82enZKamhiRGFnU25aZGlHZHhZd2lUUmhuTVptNjhoWVlJQkxIOEkzbzJNMjZCZFJyM25tdXBnQ2ZWaHV3b2p6UWJpdk9xUEhBc1dyTlNmeF9wbm5yYUhHV01UZnVXWDFlNzBkdXlWUWhvcmJpSmljbmE3LUpUZEg4VzRwZ2JVSjdYUm1sODViQXVxUzdGTmZFbVpiN2V1YW5XV3U4b2VRWmxldGVGVHZsSldoekhVLU9wZ2V0cGZIYkNqM2pXVGctQVAyUm4xTHhpd1VVLXFhcnVEV21Rby1hbTlqTl84TjVveHdYTExUVkhHQ0ltaTB2WXJnY1NQVE5PbWg3ejgySElYc1JSTlQ3NDlFUWR6STZVUjVqaXFRN200NF9LY1ljQ0R2UldlWUtKY1NQVnJ4QXRyYTBGSWVuenhyM0Z0cWtndTd1eG8xRzY5a2dNZ1hkQm5MV3BHVzA2N1QwUkd6WlRGYTZQOUhnVWQ2S0Y5U0s1dXFNVXh5Q2pLWVUxSUQ2MlR1ak52NmRIZ2hlYTk1SGZGWS1RV3hWVU9rR3d1Rk9MLS11REZXbzhqMHpsSm1HYW1jMUNLT29YOHZsRWNaLTVvOFpmT3l3MHVwaERTT0dNLWFjcGRYZ25qT2szTkVFUnRFR3JWYS1aNXFIRnMyalozTlQzNFF2NXJLVHVPVF9zdTF6ZjlkbzJ4RFc2ZENmNFFxZDZzTzhfMUl0bW96V0lPZkh1dXFYZlEteFBlSG84Si1FNS1TTi1OMkFnX2pOYW8xY3MxMVJnVC02MDUyaXZfMEVHWDQtVlRpcENmV0h3V0dCWEFRS2prQXdNRlQ5dnRFVHU0Q1dNTmh0SlBCaU55bFMydWM1TTFFLW96ODBnV3dNZHFZTWZhRURYSHlrdzF3RlRuWDBoQUhSOUJWemtRM3pxcDJFbGJoaTJ3ZktRTlJxbXltaHBoZXVJVDlxS3cxNWo2c0ZBV0NzaUstRWdsMW1xLXFkanZGYUFiU0tSLXFQa0tkcDFoMV9kak41ZjQ0R214UmtOR1ZBanRuemY3Mmw1SkZ5aDZodGIzT3N2aV85MW9kcld6c0g0ZDgtTWo3b3Y3VjJCRnR2U2tMVm9rUXNVRnVHbzZXVTZ6RmI2RkNmajBfMWVnODVFbnpkT0oyci15czJHU0p1cUowTGZJMzVnd3hIRjQyTVhKOGRkcFRKdVpyQ3Yzd01Jb1lSajFmV0paeEV0cjk1SmpmdWpDVFJMUmMtUFctOGhaTmlKQXNRVlVUNlhJemxudHZCR056SVlBb3NOTEYxRTRLaFlVd2d3TWtxVlB6ZEtQLTkxOGMyY3N0a2pYRFUweDBNaGhja2xSSklPOUZla1dKTWRNbG8tUGdSNEV5cW90OWlOZFlIUExBd3U2b2hyS1owbXVMM3p0Qm41cUtzWUxYNzB1N3JpUTNBSGdsT0NuamNTb1lIbXR4MG1sakNPVkxBUXRLVE1xX0YxWDhOcERIY1lTQVFqS01CaXZKNllFaXlIR0JsM1pKMmV1OUo3TGI1WkRaVnYxUTl1LTM0SU1qN1V1b0RCT0x0VHNLTmNLZnk1S0MxYnBBcm03WnVua0xqaEhGUzhOU253ZkppRzdudXBSVlMxeFVOSWxtZ1o2RVBSQUhEUEFuQ1hxSVZMME4yWUtaU3VyRGo3RkUyRUNjT0pNcE1BdE1ZRzdXVl8ydUtXZjdMdHdEVW4teHUtTi1HSGliLUxud21TX0NtcGVkRFBHNkZ1WTlNczR4OUJfUVluc1BoV09oWS1scUdsNnB5d1U5M1huX3k4QzAyNldtb2hybktYN2xKZ1NTNWFsaWwzV3pCRVhkaGR5eTNlV1d6ZzFfaFZTT0E4UjRpQ3pKdEZxUlJ6UFZXM3laUndyWEk2NlBXLUpoajVhZzVwQXpWVzUtVjVNZFBwdWdQa3AxZC1KdGdqNnhibjN4dmFYb2cxcEVwc1g5R09zRUdINUZtOE5QRjVUU0dpZy1QVl9odnFtVDNuWFZLSURtMXlSMlhRNTBWSVFJbEdOOWpfVWV0SmdRWDdlUXZZWE8xRUxDN1I0aEN6MHYwNzM1cmpJS0ZpMnBYWkxfb3FsbEV1VnlqWGxqdVJ6SHlwSjAzRlMycTBaQ295NXNnZERpUnJQcjhrUUd3bkI4bDVzRmxQblhkaFJPTTdISnVUQmhET3BOMTM4bjVvUEc2VmZhb2lrR1FyTUl2RWNEeGg0U0dsNnV6eU5zOUxiNDY5SXBxR0hBS00wOTgyWTFnWkQyaEtLVUloT3ZxZGh0RWVGRmJzenFsaUtfZENQM0JzdkVVeTdXR3hUSmJST1NBMUI1NkVFWncwNW5JZVVLX1p1RXdqVnFfQWpvQ08yQjZhN1NkTkpTSnUxOVRXZXE0WFEtZWxhZW1NNXYtQ2sya0VGLURmS01lMkctNVY3c2ZhN0ZGRFgwWHlabTFkeS1hcUZ1dDZ3cnpPQ3hha2IzVE11M0pqbklmU0diczBqTFBNZC1QZGp6VzNTSnJVSjJoWkJUQjVORG4tYUJmMEJtSUNUdVpEaGt6OTM3TjFOdVhXUHItZjRtZ25nU3NhZC1sVTVXNTRDTmxZbnlfeHNsdkpuMXhUYnE1MnpVQ0ZOclRWM1M4eHdXTzRXbFRZZVQtTS1iRVdXVWZMSGotcWg3MUxUYTFnSEEtanBCRHlZRUNIdGdpUFhsYjdYUndCZnRITzhMZVJ1dHFoVlVNb0duVjlxd0U4OGRuQVV3MG90R0hiYW5MWkxWVklzbWFRNzBfSUNrdzc5bVdtTXg0dExEYnRCaDI3c1I4TWFwLXZKR0wxSjRZYjZIV3ZqZjNqTWhFT0RGSDVMc1A1UzY2bDBiMGFSUy1fNVRQRzRJWDVydUpqb1ZfSHNVbldVeUN2YlAxSW5WVDdxVzJ1WHpLeUdmb0xWMDNHN05oQzY3YnhvUUdhS2xaOHNidkVvbTZtSHFlblhOYmwyR3NQdVJDRUdxREhWdF9ZcXhwUWxHc2hyLW5vUGhIUVhJNUNhY0hFU0ptVnI0TFVhZDE1TFBBUEstSkRoZWJ5MHJhUmZrR1ZrRlFtRGpxS1pOMmFMQjBsdjluY3FiYUU4eGJVVXlZVEpuNWdHVVhJMGtwaTdZR2NDbXd2eHpOQ09SeTV6N1BaVUpsR1pQVDBZcElJUUt6VnVpQmxSYnE4Y1BCWV9IRWdVV0p3enBGVHItdnBGN3NyNWFBWmkySnByWThsbDliSlExQmp3LVlBaDIyZXp6UnR6cU9rTzJmTDBlSVpON0tiWllMdm1oME1zTFl2S2ZYYllhQlY2VHNZRGtHUDY4U1lIVExLZTU4VzZxSTZrZHl1ZTBDc0g4SjI4WGYyZHV1bm9wQ3R2Z09ld1ZmUkN5alJGeHZKSHl1bWhQVXpNMzdjblpLcUhfSm02Qlh5S1FVN3lIcHl0NnlRPT0= Connector_GoogleSpeech_API_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETk5FWWM3Q0JKMzhIYTlyMkhuNjA4NlF4dk82U2NScHhTVGY3UG83NkhfX3RrcWVtWWcyLXRjU1dTT21zWEl6YWRMMUFndXpsUnJOeHh3QThsNDZKRXROTzdXRUdsT0JZajZJNVlfb0gtMXkwWm9DOERPVnpjU0pyUEZfOGJsUnprT3ltMVVhalUyUm9hMUFtZEtHUnJqOGZ4dEZjZm5SWVVTckVCWnY1UkdVSHVmUlgwbnAyc0xDQW84R3ViSko5OHVCVWZRUVNiaG1pVFB6X3EwS0FPd2dUYjhiSmRjcXh2WEZiXzI4SFZqT21tbDduUWRyVWdFZXpmcVM5ZDR0VWtzZnF5UER6cGwwS2JlLV9CSTZ0Z0IyQ1h0YW9TcmhRTXZEckp4bWhmTkt6UTNYMk4zVkpnbUJmaDIxZnoyR2dWTEYwTUFEV0w2eUdUUGpoZk9XRkt4RVF1Z1NPdUpBeTcyWV9PY1Ffd2s0ZEdVekxGekhoeEl4TmNqaXYtbUJuSVdycFducERWdWtZajZnX011Q2w4eE9VMTBqQ1ZxRmdScWhXY1E3WWhzX1JZcHhxam9FbDVPN3Q1MWtrMUZuTUg3LVFQVHp1T1hpQWNDMzEzekVJWk9ybl91YUVjSkFob1VaMi1ONEtuMnRSOEg1S3QybUMwbVZDejItajBLTjM2Zy1hNzZQMW5LLVVDVGdFWm5BZUxNeEFnUkZzU3dxV0lCUlc0LWo4b05GczVpOGZSV2ZxbFBwUml6OU5tYjdnTks3Y3hrVEZVTHlmc1NPdFh4WE5pWldEZklOQUxBbjBpMTlkX3FFQVJ6c2NSZGdzTThycE92VW82enZKamhiRGFnU25aZGlHZHhZd2lUUmhuTVptNjhoWVlJQkxIOEkzbzJNMjZCZFJyM25tdXBnQ2ZWaHV3b2p6UWJpdk9xUEhBc1dyTlNmeF9wbm5yYUhHV01UZnVXWDFlNzBkdXlWUWhvcmJpSmljbmE3LUpUZEg4VzRwZ2JVSjdYUm1sODViQXVxUzdGTmZFbVpiN2V1YW5XV3U4b2VRWmxldGVGVHZsSldoekhVLU9wZ2V0cGZIYkNqM2pXVGctQVAyUm4xTHhpd1VVLXFhcnVEV21Rby1hbTlqTl84TjVveHdYTExUVkhHQ0ltaTB2WXJnY1NQVE5PbWg3ejgySElYc1JSTlQ3NDlFUWR6STZVUjVqaXFRN200NF9LY1ljQ0R2UldlWUtKY1NQVnJ4QXRyYTBGSWVuenhyM0Z0cWtndTd1eG8xRzY5a2dNZ1hkQm5MV3BHVzA2N1QwUkd6WlRGYTZQOUhnVWQ2S0Y5U0s1dXFNVXh5Q2pLWVUxSUQ2MlR1ak52NmRIZ2hlYTk1SGZGWS1RV3hWVU9rR3d1Rk9MLS11REZXbzhqMHpsSm1HYW1jMUNLT29YOHZsRWNaLTVvOFpmT3l3MHVwaERTT0dNLWFjcGRYZ25qT2szTkVFUnRFR3JWYS1aNXFIRnMyalozTlQzNFF2NXJLVHVPVF9zdTF6ZjlkbzJ4RFc2ZENmNFFxZDZzTzhfMUl0bW96V0lPZkh1dXFYZlEteFBlSG84Si1FNS1TTi1OMkFnX2pOYW8xY3MxMVJnVC02MDUyaXZfMEVHWDQtVlRpcENmV0h3V0dCWEFRS2prQXdNRlQ5dnRFVHU0Q1dNTmh0SlBCaU55bFMydWM1TTFFLW96ODBnV3dNZHFZTWZhRURYSHlrdzF3RlRuWDBoQUhSOUJWemtRM3pxcDJFbGJoaTJ3ZktRTlJxbXltaHBoZXVJVDlxS3cxNWo2c0ZBV0NzaUstRWdsMW1xLXFkanZGYUFiU0tSLXFQa0tkcDFoMV9kak41ZjQ0R214UmtOR1ZBanRuemY3Mmw1SkZ5aDZodGIzT3N2aV85MW9kcld6c0g0ZDgtTWo3b3Y3VjJCRnR2U2tMVm9rUXNVRnVHbzZXVTZ6RmI2RkNmajBfMWVnODVFbnpkT0oyci15czJHU0p1cUowTGZJMzVnd3hIRjQyTVhKOGRkcFRKdVpyQ3Yzd01Jb1lSajFmV0paeEV0cjk1SmpmdWpDVFJMUmMtUFctOGhaTmlKQXNRVlVUNlhJemxudHZCR056SVlBb3NOTEYxRTRLaFlVd2d3TWtxVlB6ZEtQLTkxOGMyY3N0a2pYRFUweDBNaGhja2xSSklPOUZla1dKTWRNbG8tUGdSNEV5cW90OWlOZFlIUExBd3U2b2hyS1owbXVMM3p0Qm41cUtzWUxYNzB1N3JpUTNBSGdsT0NuamNTb1lIbXR4MG1sakNPVkxBUXRLVE1xX0YxWDhOcERIY1lTQVFqS01CaXZKNllFaXlIR0JsM1pKMmV1OUo3TGI1WkRaVnYxUTl1LTM0SU1qN1V1b0RCT0x0VHNLTmNLZnk1S0MxYnBBcm03WnVua0xqaEhGUzhOU253ZkppRzdudXBSVlMxeFVOSWxtZ1o2RVBSQUhEUEFuQ1hxSVZMME4yWUtaU3VyRGo3RkUyRUNjT0pNcE1BdE1ZRzdXVl8ydUtXZjdMdHdEVW4teHUtTi1HSGliLUxud21TX0NtcGVkRFBHNkZ1WTlNczR4OUJfUVluc1BoV09oWS1scUdsNnB5d1U5M1huX3k4QzAyNldtb2hybktYN2xKZ1NTNWFsaWwzV3pCRVhkaGR5eTNlV1d6ZzFfaFZTT0E4UjRpQ3pKdEZxUlJ6UFZXM3laUndyWEk2NlBXLUpoajVhZzVwQXpWVzUtVjVNZFBwdWdQa3AxZC1KdGdqNnhibjN4dmFYb2cxcEVwc1g5R09zRUdINUZtOE5QRjVUU0dpZy1QVl9odnFtVDNuWFZLSURtMXlSMlhRNTBWSVFJbEdOOWpfVWV0SmdRWDdlUXZZWE8xRUxDN1I0aEN6MHYwNzM1cmpJS0ZpMnBYWkxfb3FsbEV1VnlqWGxqdVJ6SHlwSjAzRlMycTBaQ295NXNnZERpUnJQcjhrUUd3bkI4bDVzRmxQblhkaFJPTTdISnVUQmhET3BOMTM4bjVvUEc2VmZhb2lrR1FyTUl2RWNEeGg0U0dsNnV6eU5zOUxiNDY5SXBxR0hBS00wOTgyWTFnWkQyaEtLVUloT3ZxZGh0RWVGRmJzenFsaUtfZENQM0JzdkVVeTdXR3hUSmJST1NBMUI1NkVFWncwNW5JZVVLX1p1RXdqVnFfQWpvQ08yQjZhN1NkTkpTSnUxOVRXZXE0WFEtZWxhZW1NNXYtQ2sya0VGLURmS01lMkctNVY3c2ZhN0ZGRFgwWHlabTFkeS1hcUZ1dDZ3cnpPQ3hha2IzVE11M0pqbklmU0diczBqTFBNZC1QZGp6VzNTSnJVSjJoWkJUQjVORG4tYUJmMEJtSUNUdVpEaGt6OTM3TjFOdVhXUHItZjRtZ25nU3NhZC1sVTVXNTRDTmxZbnlfeHNsdkpuMXhUYnE1MnpVQ0ZOclRWM1M4eHdXTzRXbFRZZVQtTS1iRVdXVWZMSGotcWg3MUxUYTFnSEEtanBCRHlZRUNIdGdpUFhsYjdYUndCZnRITzhMZVJ1dHFoVlVNb0duVjlxd0U4OGRuQVV3MG90R0hiYW5MWkxWVklzbWFRNzBfSUNrdzc5bVdtTXg0dExEYnRCaDI3c1I4TWFwLXZKR0wxSjRZYjZIV3ZqZjNqTWhFT0RGSDVMc1A1UzY2bDBiMGFSUy1fNVRQRzRJWDVydUpqb1ZfSHNVbldVeUN2YlAxSW5WVDdxVzJ1WHpLeUdmb0xWMDNHN05oQzY3YnhvUUdhS2xaOHNidkVvbTZtSHFlblhOYmwyR3NQdVJDRUdxREhWdF9ZcXhwUWxHc2hyLW5vUGhIUVhJNUNhY0hFU0ptVnI0TFVhZDE1TFBBUEstSkRoZWJ5MHJhUmZrR1ZrRlFtRGpxS1pOMmFMQjBsdjluY3FiYUU4eGJVVXlZVEpuNWdHVVhJMGtwaTdZR2NDbXd2eHpOQ09SeTV6N1BaVUpsR1pQVDBZcElJUUt6VnVpQmxSYnE4Y1BCWV9IRWdVV0p3enBGVHItdnBGN3NyNWFBWmkySnByWThsbDliSlExQmp3LVlBaDIyZXp6UnR6cU9rTzJmTDBlSVpON0tiWllMdm1oME1zTFl2S2ZYYllhQlY2VHNZRGtHUDY4U1lIVExLZTU4VzZxSTZrZHl1ZTBDc0g4SjI4WGYyZHV1bm9wQ3R2Z09ld1ZmUkN5alJGeHZKSHl1bWhQVXpNMzdjblpLcUhfSm02Qlh5S1FVN3lIcHl0NnlRPT0=

View file

@ -31,9 +31,20 @@ APP_LOGGING_FILE_ENABLED = True
APP_LOGGING_ROTATION_SIZE = 10485760 APP_LOGGING_ROTATION_SIZE = 10485760
APP_LOGGING_BACKUP_COUNT = 5 APP_LOGGING_BACKUP_COUNT = 5
# Service Redirects # OAuth: Auth app (login/JWT) vs Data app (Graph / Google APIs)
Service_MSFT_REDIRECT_URI = https://gateway-int.poweron-center.net/api/msft/auth/callback Service_MSFT_AUTH_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_GOOGLE_REDIRECT_URI = https://gateway-int.poweron-center.net/api/google/auth/callback Service_MSFT_AUTH_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm83T29rMDZvcV9qTG5xb1FzUkdqS1llbzRxSEJXbmpONFFtcUtfZXdtZjQybmJSMjBjMEpnRVhiOGRuczZvVFBFdVVTQV80SG9PSnRQTEpLdVViNm5wc2E5aGRLWjZ4TGF1QjVkNmdRSzBpNWNkYXVublFYclVEdEM5TVBBZWVVMW5RVWk=
Service_MSFT_AUTH_REDIRECT_URI = https://gateway-int.poweron-center.net/api/msft/auth/login/callback
Service_MSFT_DATA_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_MSFT_DATA_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm83T29rMDZvcV9qTG5xb1FzUkdqS1llbzRxSEJXbmpONFFtcUtfZXdtZjQybmJSMjBjMEpnRVhiOGRuczZvVFBFdVVTQV80SG9PSnRQTEpLdVViNm5wc2E5aGRLWjZ4TGF1QjVkNmdRSzBpNWNkYXVublFYclVEdEM5TVBBZWVVMW5RVWk=
Service_MSFT_DATA_REDIRECT_URI = https://gateway-int.poweron-center.net/api/msft/auth/connect/callback
Service_GOOGLE_AUTH_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_AUTH_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNThGeVRNd3hacThtRnE0bzlDa0JPUWQyaEd6QjlFckdsMGZjRlRfUks2bXV3aDdVRTF3LVRlZVY5WjVzSXV4ZGNnX002RDl3dkNYdGFzZkxVUW01My1wTHRCanVCLUozZEx4TlduQlB5MnpvNTR2SGlvbFl1YkhzTEtsSi1SOEo=
Service_GOOGLE_AUTH_REDIRECT_URI = https://gateway-int.poweron-center.net/api/google/auth/login/callback
Service_GOOGLE_DATA_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_DATA_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNThGeVRNd3hacThtRnE0bzlDa0JPUWQyaEd6QjlFckdsMGZjRlRfUks2bXV3aDdVRTF3LVRlZVY5WjVzSXV4ZGNnX002RDl3dkNYdGFzZkxVUW01My1wTHRCanVCLUozZEx4TlduQlB5MnpvNTR2SGlvbFl1YkhzTEtsSi1SOEo=
Service_GOOGLE_DATA_REDIRECT_URI = https://gateway-int.poweron-center.net/api/google/auth/connect/callback
# Stripe Billing (both end with _SECRET for encryption script) # Stripe Billing (both end with _SECRET for encryption script)
STRIPE_SECRET_KEY_SECRET = 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 STRIPE_SECRET_KEY_SECRET = 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
@ -48,15 +59,8 @@ Connector_AiTavily_API_SECRET = INT_ENC:Z0FBQUFBQm8xSVRkdkJMTDY0akhXNzZDWHVYSEt1
Connector_AiPrivateLlm_API_SECRET = INT_ENC:Z0FBQUFBQnBudkpGSjZ1NWh0aWc1R3Z4MHNaeS1HamtUbndhcUZFZDlqUDhjSmg5eHFfdlVkU0RsVkJ2UVRaMWs3aWhraG5jSlc0YkxNWHVmR2JoSW5ENFFCdkJBM0VienlKSnhzNnBKbTJOUTFKczRfWlQ3bWpmUkRTT1I1OGNUSTlQdExacGRpeXg= Connector_AiPrivateLlm_API_SECRET = INT_ENC:Z0FBQUFBQnBudkpGSjZ1NWh0aWc1R3Z4MHNaeS1HamtUbndhcUZFZDlqUDhjSmg5eHFfdlVkU0RsVkJ2UVRaMWs3aWhraG5jSlc0YkxNWHVmR2JoSW5ENFFCdkJBM0VienlKSnhzNnBKbTJOUTFKczRfWlQ3bWpmUkRTT1I1OGNUSTlQdExacGRpeXg=
Connector_AiMistral_API_SECRET = INT_ENC:Z0FBQUFBQnBudkpGZTNtZ1E4TWIxSEU1OUlreUpxZkJIR0Vxcm9xRHRUbnBxbTQ1cXlkbnltWkJVdTdMYWZ4c3Fsam42TERWUTVhNzZFMU9xVjdyRGFCYml6bmZsZFd2YmJzemlrSWN6Q3o3X0NXX2xXNUQteTNONHdKYzJ5YVpLLWdhU2JhSTJQZnI= Connector_AiMistral_API_SECRET = INT_ENC:Z0FBQUFBQnBudkpGZTNtZ1E4TWIxSEU1OUlreUpxZkJIR0Vxcm9xRHRUbnBxbTQ1cXlkbnltWkJVdTdMYWZ4c3Fsam42TERWUTVhNzZFMU9xVjdyRGFCYml6bmZsZFd2YmJzemlrSWN6Q3o3X0NXX2xXNUQteTNONHdKYzJ5YVpLLWdhU2JhSTJQZnI=
# Microsoft Service Configuration
Service_MSFT_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_MSFT_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm83T29rMDZvcV9qTG5xb1FzUkdqS1llbzRxSEJXbmpONFFtcUtfZXdtZjQybmJSMjBjMEpnRVhiOGRuczZvVFBFdVVTQV80SG9PSnRQTEpLdVViNm5wc2E5aGRLWjZ4TGF1QjVkNmdRSzBpNWNkYXVublFYclVEdEM5TVBBZWVVMW5RVWk=
Service_MSFT_TENANT_ID = common Service_MSFT_TENANT_ID = common
# Google Service configuration
Service_GOOGLE_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNThGeVRNd3hacThtRnE0bzlDa0JPUWQyaEd6QjlFckdsMGZjRlRfUks2bXV3aDdVRTF3LVRlZVY5WjVzSXV4ZGNnX002RDl3dkNYdGFzZkxVUW01My1wTHRCanVCLUozZEx4TlduQlB5MnpvNTR2SGlvbFl1YkhzTEtsSi1SOEo=
# Google Cloud Speech Services configuration # Google Cloud Speech Services configuration
Connector_GoogleSpeech_API_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRkNmVXZ1pWcHcydTF2MXF0ZGJoWHBydF85bTczTktiaEJ3Wk1vMW1mZVhDSG1yd0ZxR2ZuSGJTX0N3MWptWXFJTkNTWjh1SUVVTXI4UDVzcGdLMkU5SHJ2TUpkRlRoRWdnSldtYjNTQkh4UDJHY2xmdTdZQ1ZiMTZZcGZxS3RzaHdjV3dtVkZUcEpJcWx0b2xuQVR6ZmpoVFZPY1hNMTV2SnhDaC1IZEh4UUpLTy1ILXA4RG1zamJTbUJ4X0t2M2NkdzJPbEJxSmFpRzV3WC0wZThoVzlxcmpHZ3ZkLVlVY3REZk1vV19WQ05BOWN6cnJ4MWNYYnNiQ0FQSUVnUlpfM3BhMnlsVlZUOG5wM3pzM1lSN1UzWlZKUXRLczlHbjI1LTFvSUJ4SlVXMy1BNk43bE5Hb0RfTTVlWk9oZnFIaVg0SW5pbm9EcXRTTzU1RFlYY3dTcnpKWWNyNjN5T1BGZ0FmX253cEFncmhvZVRuM05KYzhkOEhFMFJsc2NBSEwzZVZ1R0JMOGxsekVwUE55alZaRXFrdzNWWVNGWXNmbnhKeWhQSFo2VXBTUlRPeHdvdVdncEFuOWgydEtsSUFneUN6cGVaTnBSdjNCdVJseGJFdmlMc203UFhLVlYyTENkaGg2dVN6Z2xwT1ZmTmN5bVZGUkM3ZWcyVkt2ckFUVVd3WFFwYnJjNVRobEh2SkVJbXRwUUpEOFJKQ1NUc0Q4NHNqUFhPSDh5cTV6MEcwSDEwRUJCQ2JiTTJlOE5nd3pMMkJaQ1dVYjMwZVVWWnlETmp2dkZ3aXEtQ29WNkxZTFkzYUkxdTlQUU1OTnhWWU12YU9MVnJQa1d2ZjRtUlhneTNubEMxTmp1eUNPOThSMlB3Y1F0T2tCdFNsNFlKalZPV25yR2QycVBUb096RmZ1V0FTaGsxLV9FWDBmenBIOXpMdGpLcUc0TWRoY2hlMFhYTzlET1ZRekw0ZHNwUVBQdVJBX2h6Q2ZzWVZJWTNybTJiekp3WmhmWF9SUFBXQzlqUjctcVlHWWVMZWVQallzR0JGTVF0WmtnWlg1aTM1bFprNVExZXY5dnNvWF93UjhwbkJ3RzNXaVJ2d2RRU3JJVlBvaVh4eTlBRUtqWkJia3dJQVVBV2Nqdm9FUTRUVW1TaHp2ZUwxT0N2ZndxQ2Nka1RYWXF0LWxIWFE0dTFQcVhncFFPM0hFdUUtYlFnemx3WkF4bjA1aDFULUdrZlVZbEJtRGRCdjJyVkdJSXozd0I0dF9zbWhOeHFqRDA4T1NVaWR5cjBwSVgwbllPU294NjZGTnM1bFhIdGpNQUxFOENWd3FCbGpSRFRmRXotQnU0N2lCVEU5RGF6Qi10S2U2NGdadDlrRjZtVE5oZkw5ZWFjXzhCTmxXQzNFTFgxRXVYY3J3YkxnbnlBSm9PY3h4MlM1NVFQbVNDRW5Ld1dvNWMxSmdoTXJuaE1pT2VFeXYwWXBHZ29MZDVlN2lwUUNIeGNCVVdQVi1rRXdJMWFncUlPTXR0MmZVQ1l0d09mZTdzWGFBWUJMUFd3b0RSOU8zeER2UWpNdzAxS0ZJWnB5S3FJdU9wUDJnTTNwMWw3VFVqVXQ3ZGZnU1RkUktkc0NhUHJ0SGFxZ0lVWDEzYjNtU2JfMGNWM1Y0dHlCTzNESEdENC1jUWF5MVppRzR1QlBNSUJySjFfRi1ENHEwcmJ4S3hQUFpXVHA0TG9DZWdoUlo5WnNSM1lCZm1KbEs2ak1yUUU4Wk9JcVJGUkJwc0NvUkMyTjhoTWxtZmVQeDREZVRKZkhYN2duLVNTeGZzdFdBVnhEandJSXB5QjM0azF0ckI3Tk1wSzFhNGVOUVRrNjU0cG9JQ29pN09xOFkwR1lMTlktaGp4TktxdTVtTnNEcldsV2pEZm5nQWpJc2hxY0hjQnVSWUR5VVdaUXBHWUloTzFZUC1oNzJ4UjZ1dnpLcDJxWEZtQlNIMWkzZ0hXWXdKeC1iLXdZWVJhcU04VFlpMU5pd2ZIdTdCdkVWVFVBdmJuRk16bEFFQTh4alBrcTV2RzliT2hGdTVPOXlRMjFuZktiRTZIamQ1VFVqS0hRTXhxcU1mdkgyQ1NjQmZfcjl4c3NJd0RIeDVMZUFBbHJqdEJxWWl3aWdGUEQxR3ZnMkNGdVB4RUxkZi1xOVlFQXh1NjRfbkFEaEJ5TVZlUGFrWVhSTVRPeGxqNlJDTHNsRWRrei1pYjhnUmZrb3BvWkQ2QXBzYjFHNXZoWU1LSExhLWtlYlJTZlJmYUM5Y1Rhb1pkMVYyWTByM3NTS0VXMG1ybm1BTVN2QXRYaXZqX2dKSkZrajZSS2cyVlNOQnd5Y29zMlVyaWlNbTJEb3FuUFFtbWNTNVpZTktUenFZSl91cVFXZjRkQUZyYmtPczU2S1RKQ19ONGFOTHlwX2hOOEE1UHZEVjhnT0xxRjMxTEE4SHhRbmlmTkZwVXJBdlJDbU5oZS05SzI4QVhEWDZaN2ZiSlFwUGRXSnB5TE9MZV9ia3pYcmZVa1dicG5FMHRXUFZXMWJQVDAwOEdDQzJmZEl0ZDhUOEFpZXZWWXl5Q2xwSmFienNCMldlb2NKb2ZRYV9KbUdHRzNUcjU1VUFhMzk1a2J6dDVuNTl6NTdpM0hGa3k0UWVtbF9pdDVsQVp2cndDLUU5dnNYOF9CLS0ySXhBSFdCSnpqV010bllBb3U0cEZZYVF5R2tSNFM5NlRhdS1fb1NqbDBKMkw0V2N0VEZhNExtQlR3ckZ3cVlCeHVXdXJ6X0s4cEtsaG5rVUxCN2RRbHQxTmcyVFBqYUxyOHJzeFBXVUJaRHpXbUoxdHZzMFBzQk1UTUFvX1pGNFNMNDFvZWdTdEUtMUNKMXNIeVlvQk1CeEdpZVdmN0tsSDVZZHJXSGt5c2o2MHdwSTZIMVBhRzM1eU43Q2FtcVNidExxczNJeUx5U2RuUG5EeHpCTlg2SV9WNk1ET3BRNXFuc0pNWlVvZUYtY21oRGtJSmwxQ09QbHBUV3BuS3B5NE9RVkhfellqZjJUQ0diSV94QlhQWmdaaC1TRWxsMUVWSXB0aE1McFZDZDNwQUVKZ2t5cXRTXzlRZVJwN0pZSnJSV21XMlh0TzFRVEl0c2I4QjBxOGRCYkNxek04a011X1lrb2poQ3h2LUhKTGJiUlhneHp5QWFBcE5nMElkNTVzM3JGOWtUQ19wNVBTaVVHUHFDNFJnNXJaWDNBSkMwbi1WbTdtSnFySkhNQl9ZQjZrR2xDcXhTRExhMmNHcGlyWjR3ZU9SSjRZd1l4ZjVPeHNiYk53SW5SYnZPTzNkd1lnZmFseV9tQ3BxM3lNYVBHT0J0elJnMTByZ3VHemxta0tVQzZZRllmQ2VLZ1ZCNDhUUTc3LWNCZXBMekFwWW1fQkQ1NktzNGFMYUdYTU0xbXprY1FONUNlUHNMY3h2NFJMMmhNa3VNdzF4TVFWQk9odnJUMjFJMVd3Z2N6Sms5aEM2SWlWZFViZ0JWTEpUWWM5NmIzOS1oQmRqdkt1NUUycFlVcUxERUZGbnZqTUxIYnJmMDBHZDEzbnJsWEEzSUo3UmNPUDg1dnRUU1FzcWtjTWZwUG9zM0JTY3RqMDdST2UxcXFTM0d0bGkwdFhnMk5LaUlxNWx3V1pLaVlLUFJXZzBzVl9Ia1V1OHdYUEFWOU50UndycGtCdzM0Q0NQamp2VTNqbFBLaGhsbUk5dUI5MjU5OHVySk1oY0drUWtXUloyVVRvOWJmbUVYRzFVeWNQczh2NXJCeVppRlZiWDNJaDhOSmRmX2lURTNVS3NXQXFZT1QtUmdvMWJoVWYxU3lqUUJhbzEyX3I3TXhwbm9wc1FoQ1ZUTlNBRjMyQTBTY2tzbHZ3RFUtTjVxQ0o1QXRTVks2WENwMGZCRGstNU1jN3FhUFJCQThyaFhhMVRsbnlSRXNGRmt3Yk01X21ldmV3bTItWm1JaGpZQWZROEFtT1d1UUtPQlhYVVFqT2NxLUxQenJHX3JfMEdscDRiMXcyZ1ZmU3NFMzVoelZJaDlvT0ZoRGQ2bmtlM0M5ZHlCd2ZMbnRZRkZUWHVBUEx4czNfTmtMckh5eXZrZFBzOEItOGRYOEhsMzBhZ0xlOWFjZzgteVBsdnpPT1pYdUxnbFNXYnhKaVB6QUxVdUJCOFpvU2x2c1FHZV94MDBOVWJhYkxISkswc0U5UmdPWFJLXzZNYklHTjN1QzRKaldKdEVHb0pOU284N3c2LXZGMGVleEZ5NGZ6OGV1dm1tM0J0aTQ3VFlNOEJrdEh3PT0= Connector_GoogleSpeech_API_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRkNmVXZ1pWcHcydTF2MXF0ZGJoWHBydF85bTczTktiaEJ3Wk1vMW1mZVhDSG1yd0ZxR2ZuSGJTX0N3MWptWXFJTkNTWjh1SUVVTXI4UDVzcGdLMkU5SHJ2TUpkRlRoRWdnSldtYjNTQkh4UDJHY2xmdTdZQ1ZiMTZZcGZxS3RzaHdjV3dtVkZUcEpJcWx0b2xuQVR6ZmpoVFZPY1hNMTV2SnhDaC1IZEh4UUpLTy1ILXA4RG1zamJTbUJ4X0t2M2NkdzJPbEJxSmFpRzV3WC0wZThoVzlxcmpHZ3ZkLVlVY3REZk1vV19WQ05BOWN6cnJ4MWNYYnNiQ0FQSUVnUlpfM3BhMnlsVlZUOG5wM3pzM1lSN1UzWlZKUXRLczlHbjI1LTFvSUJ4SlVXMy1BNk43bE5Hb0RfTTVlWk9oZnFIaVg0SW5pbm9EcXRTTzU1RFlYY3dTcnpKWWNyNjN5T1BGZ0FmX253cEFncmhvZVRuM05KYzhkOEhFMFJsc2NBSEwzZVZ1R0JMOGxsekVwUE55alZaRXFrdzNWWVNGWXNmbnhKeWhQSFo2VXBTUlRPeHdvdVdncEFuOWgydEtsSUFneUN6cGVaTnBSdjNCdVJseGJFdmlMc203UFhLVlYyTENkaGg2dVN6Z2xwT1ZmTmN5bVZGUkM3ZWcyVkt2ckFUVVd3WFFwYnJjNVRobEh2SkVJbXRwUUpEOFJKQ1NUc0Q4NHNqUFhPSDh5cTV6MEcwSDEwRUJCQ2JiTTJlOE5nd3pMMkJaQ1dVYjMwZVVWWnlETmp2dkZ3aXEtQ29WNkxZTFkzYUkxdTlQUU1OTnhWWU12YU9MVnJQa1d2ZjRtUlhneTNubEMxTmp1eUNPOThSMlB3Y1F0T2tCdFNsNFlKalZPV25yR2QycVBUb096RmZ1V0FTaGsxLV9FWDBmenBIOXpMdGpLcUc0TWRoY2hlMFhYTzlET1ZRekw0ZHNwUVBQdVJBX2h6Q2ZzWVZJWTNybTJiekp3WmhmWF9SUFBXQzlqUjctcVlHWWVMZWVQallzR0JGTVF0WmtnWlg1aTM1bFprNVExZXY5dnNvWF93UjhwbkJ3RzNXaVJ2d2RRU3JJVlBvaVh4eTlBRUtqWkJia3dJQVVBV2Nqdm9FUTRUVW1TaHp2ZUwxT0N2ZndxQ2Nka1RYWXF0LWxIWFE0dTFQcVhncFFPM0hFdUUtYlFnemx3WkF4bjA1aDFULUdrZlVZbEJtRGRCdjJyVkdJSXozd0I0dF9zbWhOeHFqRDA4T1NVaWR5cjBwSVgwbllPU294NjZGTnM1bFhIdGpNQUxFOENWd3FCbGpSRFRmRXotQnU0N2lCVEU5RGF6Qi10S2U2NGdadDlrRjZtVE5oZkw5ZWFjXzhCTmxXQzNFTFgxRXVYY3J3YkxnbnlBSm9PY3h4MlM1NVFQbVNDRW5Ld1dvNWMxSmdoTXJuaE1pT2VFeXYwWXBHZ29MZDVlN2lwUUNIeGNCVVdQVi1rRXdJMWFncUlPTXR0MmZVQ1l0d09mZTdzWGFBWUJMUFd3b0RSOU8zeER2UWpNdzAxS0ZJWnB5S3FJdU9wUDJnTTNwMWw3VFVqVXQ3ZGZnU1RkUktkc0NhUHJ0SGFxZ0lVWDEzYjNtU2JfMGNWM1Y0dHlCTzNESEdENC1jUWF5MVppRzR1QlBNSUJySjFfRi1ENHEwcmJ4S3hQUFpXVHA0TG9DZWdoUlo5WnNSM1lCZm1KbEs2ak1yUUU4Wk9JcVJGUkJwc0NvUkMyTjhoTWxtZmVQeDREZVRKZkhYN2duLVNTeGZzdFdBVnhEandJSXB5QjM0azF0ckI3Tk1wSzFhNGVOUVRrNjU0cG9JQ29pN09xOFkwR1lMTlktaGp4TktxdTVtTnNEcldsV2pEZm5nQWpJc2hxY0hjQnVSWUR5VVdaUXBHWUloTzFZUC1oNzJ4UjZ1dnpLcDJxWEZtQlNIMWkzZ0hXWXdKeC1iLXdZWVJhcU04VFlpMU5pd2ZIdTdCdkVWVFVBdmJuRk16bEFFQTh4alBrcTV2RzliT2hGdTVPOXlRMjFuZktiRTZIamQ1VFVqS0hRTXhxcU1mdkgyQ1NjQmZfcjl4c3NJd0RIeDVMZUFBbHJqdEJxWWl3aWdGUEQxR3ZnMkNGdVB4RUxkZi1xOVlFQXh1NjRfbkFEaEJ5TVZlUGFrWVhSTVRPeGxqNlJDTHNsRWRrei1pYjhnUmZrb3BvWkQ2QXBzYjFHNXZoWU1LSExhLWtlYlJTZlJmYUM5Y1Rhb1pkMVYyWTByM3NTS0VXMG1ybm1BTVN2QXRYaXZqX2dKSkZrajZSS2cyVlNOQnd5Y29zMlVyaWlNbTJEb3FuUFFtbWNTNVpZTktUenFZSl91cVFXZjRkQUZyYmtPczU2S1RKQ19ONGFOTHlwX2hOOEE1UHZEVjhnT0xxRjMxTEE4SHhRbmlmTkZwVXJBdlJDbU5oZS05SzI4QVhEWDZaN2ZiSlFwUGRXSnB5TE9MZV9ia3pYcmZVa1dicG5FMHRXUFZXMWJQVDAwOEdDQzJmZEl0ZDhUOEFpZXZWWXl5Q2xwSmFienNCMldlb2NKb2ZRYV9KbUdHRzNUcjU1VUFhMzk1a2J6dDVuNTl6NTdpM0hGa3k0UWVtbF9pdDVsQVp2cndDLUU5dnNYOF9CLS0ySXhBSFdCSnpqV010bllBb3U0cEZZYVF5R2tSNFM5NlRhdS1fb1NqbDBKMkw0V2N0VEZhNExtQlR3ckZ3cVlCeHVXdXJ6X0s4cEtsaG5rVUxCN2RRbHQxTmcyVFBqYUxyOHJzeFBXVUJaRHpXbUoxdHZzMFBzQk1UTUFvX1pGNFNMNDFvZWdTdEUtMUNKMXNIeVlvQk1CeEdpZVdmN0tsSDVZZHJXSGt5c2o2MHdwSTZIMVBhRzM1eU43Q2FtcVNidExxczNJeUx5U2RuUG5EeHpCTlg2SV9WNk1ET3BRNXFuc0pNWlVvZUYtY21oRGtJSmwxQ09QbHBUV3BuS3B5NE9RVkhfellqZjJUQ0diSV94QlhQWmdaaC1TRWxsMUVWSXB0aE1McFZDZDNwQUVKZ2t5cXRTXzlRZVJwN0pZSnJSV21XMlh0TzFRVEl0c2I4QjBxOGRCYkNxek04a011X1lrb2poQ3h2LUhKTGJiUlhneHp5QWFBcE5nMElkNTVzM3JGOWtUQ19wNVBTaVVHUHFDNFJnNXJaWDNBSkMwbi1WbTdtSnFySkhNQl9ZQjZrR2xDcXhTRExhMmNHcGlyWjR3ZU9SSjRZd1l4ZjVPeHNiYk53SW5SYnZPTzNkd1lnZmFseV9tQ3BxM3lNYVBHT0J0elJnMTByZ3VHemxta0tVQzZZRllmQ2VLZ1ZCNDhUUTc3LWNCZXBMekFwWW1fQkQ1NktzNGFMYUdYTU0xbXprY1FONUNlUHNMY3h2NFJMMmhNa3VNdzF4TVFWQk9odnJUMjFJMVd3Z2N6Sms5aEM2SWlWZFViZ0JWTEpUWWM5NmIzOS1oQmRqdkt1NUUycFlVcUxERUZGbnZqTUxIYnJmMDBHZDEzbnJsWEEzSUo3UmNPUDg1dnRUU1FzcWtjTWZwUG9zM0JTY3RqMDdST2UxcXFTM0d0bGkwdFhnMk5LaUlxNWx3V1pLaVlLUFJXZzBzVl9Ia1V1OHdYUEFWOU50UndycGtCdzM0Q0NQamp2VTNqbFBLaGhsbUk5dUI5MjU5OHVySk1oY0drUWtXUloyVVRvOWJmbUVYRzFVeWNQczh2NXJCeVppRlZiWDNJaDhOSmRmX2lURTNVS3NXQXFZT1QtUmdvMWJoVWYxU3lqUUJhbzEyX3I3TXhwbm9wc1FoQ1ZUTlNBRjMyQTBTY2tzbHZ3RFUtTjVxQ0o1QXRTVks2WENwMGZCRGstNU1jN3FhUFJCQThyaFhhMVRsbnlSRXNGRmt3Yk01X21ldmV3bTItWm1JaGpZQWZROEFtT1d1UUtPQlhYVVFqT2NxLUxQenJHX3JfMEdscDRiMXcyZ1ZmU3NFMzVoelZJaDlvT0ZoRGQ2bmtlM0M5ZHlCd2ZMbnRZRkZUWHVBUEx4czNfTmtMckh5eXZrZFBzOEItOGRYOEhsMzBhZ0xlOWFjZzgteVBsdnpPT1pYdUxnbFNXYnhKaVB6QUxVdUJCOFpvU2x2c1FHZV94MDBOVWJhYkxISkswc0U5UmdPWFJLXzZNYklHTjN1QzRKaldKdEVHb0pOU284N3c2LXZGMGVleEZ5NGZ6OGV1dm1tM0J0aTQ3VFlNOEJrdEh3PT0=

View file

@ -31,9 +31,20 @@ APP_LOGGING_FILE_ENABLED = True
APP_LOGGING_ROTATION_SIZE = 10485760 APP_LOGGING_ROTATION_SIZE = 10485760
APP_LOGGING_BACKUP_COUNT = 5 APP_LOGGING_BACKUP_COUNT = 5
# Service Redirects # OAuth: Auth app (login/JWT) vs Data app (Graph / Google APIs)
Service_MSFT_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/msft/auth/callback Service_MSFT_AUTH_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_GOOGLE_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/google/auth/callback Service_MSFT_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBESkk2T25scFU1T1pNd2FENTFRM3kzcEpSXy1HT0trQkR2Wnl3U3RYbExzRy1YUTkxd3lPZE84U2lhX3FZanp5TjhYRGluLXVjU3hjaWRBUnZLbVhtRDItZ3FxNXJ3MUxicUZTXzJWZVNrR0VKN3ZlNEtET1ppOFk0MzNmbkwyRmROUk4=
Service_MSFT_AUTH_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/msft/auth/login/callback
Service_MSFT_DATA_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_MSFT_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBESkk2T25scFU1T1pNd2FENTFRM3kzcEpSXy1HT0trQkR2Wnl3U3RYbExzRy1YUTkxd3lPZE84U2lhX3FZanp5TjhYRGluLXVjU3hjaWRBUnZLbVhtRDItZ3FxNXJ3MUxicUZTXzJWZVNrR0VKN3ZlNEtET1ppOFk0MzNmbkwyRmROUk4=
Service_MSFT_DATA_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/msft/auth/connect/callback
Service_GOOGLE_AUTH_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3eWFwSEZ4YnRJcjU1OW5kcXZKdkt1Z3gzWDFhVW5Eelh3VnpnNlppcWxweHY5UUQzeDIyVk83cW1XNVE4bllVWnR2MjlSQzFrV1UyUVV6OUt5b3Vqa3QzMUIwNFBqc2FVSXRxTlQ1OHVJZVFibnhBQ2puXzBwSXp5NUZhZjM1d1o=
Service_GOOGLE_AUTH_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/google/auth/login/callback
Service_GOOGLE_DATA_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3eWFwSEZ4YnRJcjU1OW5kcXZKdkt1Z3gzWDFhVW5Eelh3VnpnNlppcWxweHY5UUQzeDIyVk83cW1XNVE4bllVWnR2MjlSQzFrV1UyUVV6OUt5b3Vqa3QzMUIwNFBqc2FVSXRxTlQ1OHVJZVFibnhBQ2puXzBwSXp5NUZhZjM1d1o=
Service_GOOGLE_DATA_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/google/auth/connect/callback
# Stripe Billing (both end with _SECRET for encryption script) # Stripe Billing (both end with _SECRET for encryption script)
STRIPE_SECRET_KEY_SECRET = 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 STRIPE_SECRET_KEY_SECRET = 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
@ -48,15 +59,8 @@ Connector_AiTavily_API_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3NmItcDh6V0JpcE5Jc0NlUWZ
Connector_AiPrivateLlm_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGanZ6U3pzZWkwXzVPWGtIQ040XzFrTXc5QWRnazdEeEktaUJ0akJmNnEzbWUzNHczLTJfc2dIdzBDY0FTaXZYcDhxNFdNbTNtbEJTb2VRZ0ZYd05hdlNLR1h6SUFzVml2Z1FLY1BjTl90UWozUGxtak1URnhhZmNDRWFTb0dKVUo= Connector_AiPrivateLlm_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGanZ6U3pzZWkwXzVPWGtIQ040XzFrTXc5QWRnazdEeEktaUJ0akJmNnEzbWUzNHczLTJfc2dIdzBDY0FTaXZYcDhxNFdNbTNtbEJTb2VRZ0ZYd05hdlNLR1h6SUFzVml2Z1FLY1BjTl90UWozUGxtak1URnhhZmNDRWFTb0dKVUo=
Connector_AiMistral_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGc2tQc2lvMk1YZk01Q1dob1U5cnR0dG03WWE3WkpoOWo0SEpvLU9Rc2lCNDExdy1wZExaN3lpT2FEQkxnaHRmWmZUUUZUUUJmblZreGlpaFpOdnFhbzlEd1RsVVJtX216cmhxTm5BcTN2eUZ2T054cDE5bmlEamJ3NGR6MVpFQnA= Connector_AiMistral_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGc2tQc2lvMk1YZk01Q1dob1U5cnR0dG03WWE3WkpoOWo0SEpvLU9Rc2lCNDExdy1wZExaN3lpT2FEQkxnaHRmWmZUUUZUUUJmblZreGlpaFpOdnFhbzlEd1RsVVJtX216cmhxTm5BcTN2eUZ2T054cDE5bmlEamJ3NGR6MVpFQnA=
# Microsoft Service Configuration
Service_MSFT_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_MSFT_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBESkk2T25scFU1T1pNd2FENTFRM3kzcEpSXy1HT0trQkR2Wnl3U3RYbExzRy1YUTkxd3lPZE84U2lhX3FZanp5TjhYRGluLXVjU3hjaWRBUnZLbVhtRDItZ3FxNXJ3MUxicUZTXzJWZVNrR0VKN3ZlNEtET1ppOFk0MzNmbkwyRmROUk4=
Service_MSFT_TENANT_ID = common Service_MSFT_TENANT_ID = common
# Google Service configuration
Service_GOOGLE_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3eWFwSEZ4YnRJcjU1OW5kcXZKdkt1Z3gzWDFhVW5Eelh3VnpnNlppcWxweHY5UUQzeDIyVk83cW1XNVE4bllVWnR2MjlSQzFrV1UyUVV6OUt5b3Vqa3QzMUIwNFBqc2FVSXRxTlQ1OHVJZVFibnhBQ2puXzBwSXp5NUZhZjM1d1o=
# Google Cloud Speech Services configuration # Google Cloud Speech Services configuration
Connector_GoogleSpeech_API_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z4NFQxaF9uN3h1cVB6dnZid1c1R1VfNDlSQ1NHMEVDZWtKanpMQ29CLXc1MXBqRm1hQ0YtWVhaejBMY1ZTOEFEVlpWQ3hrYkFza1E2RDNsYkdMMndNR0VGNTMwVDRGdURJY3hyaVFxVjEtSEYwNHJzeWM3WmlpZW9jU2E3NTgycEV2allqQ3dJRTNyRFAzaDJ6dklKeXpNRkJhYjFzUkptN2dpbkNpMklrcGxuZl9vTkt3T0JvNm1YTXd5UlkwZWptUXdWVFpnV2J4X3J2WUhIUlFkSElFVnlqMnlJRnNHTnlpMWs2R1dZc2ROWjNYZG85cndmd1E5cUZnVmZRYnVjTG43dXFmSWd2bGFfVWFWSmtpWkpndWNlSUNwcnFNU2NqZXFaV0xsY3l3SElLRkVHcHZGZERKV1ltcGhTS0dhTko1VTJLYzNoZjRkSGVEX3dTMWVVTmdDczV5cE1JQUdSbUJGUm11eFhTVjJHbkt0SzB4UG1Dc2xmbnp1Y041Y2RTeWRuWGdmQy1sTGx0MGtnM2VJQ3EyLXViRlNhTU9ybzZkR1N1bXE5SXhlZENWRFpWSGlYOWx4SUQ3UlR0ZEVxQkxNakRUVFRiUmFnbklOalphLUZkRFVVaXBRUk5NZW5PaUZydTFmQkNPSTdTVTNZd0plWXllNVFJdmN4MVcyTGlwMGFtVjBzOGRxR1FjbzhfYW5zdTB0ZEZBTTJhakltazh1dktNMUZsOUItdFdTb1pIaUxySllXNkdlY20zUS0wTnpFNTB2SU5acG1VcXhyaHBmME8takw3RDh5T043T2VGOV92TzNya2pWSlpYVjZDdXlZcjM3a0hPTlhkaW9oQmxqQlpGRFYyTTY4WmZmT3k4Tk1tdXRuSGdTUVpNT2NKenhXb05PdXBfSEdhMTNxNjdpNXlKUUI2YUgydFFPX1VvXzVJb0UxWTU2YVNiNDQ0QndZanhMMHR1cGdHWGhvcEg1QXEtSXZJdTdZUE12ZEVVWkF4QmtsQS1GYnY3SFIxSHlsOGVfcEpGS1A4QUVEQWNEOFZYYlljQ3ByTU03YU16Y0UzUnJQZEprSWNjT1ZXVEtDWi03Y3ZzRVdYUTlabXJISEo5THRHVXVuM0xqbzA4bGVlZVpOMk1QMmptb21tV0pTMlVoOXdWVU95UW1iQmttc2w1RG9mMWwxXzg1T2IxYUVmTUJEZkpUdTFDTzZ3RlBFeUFiX01iRTZNWkNaSG45TkFOM2pzbUJRZ2N0VFpoejJUTG1RODY3TzZpSzVkYUQzaEpfY2pSTkRzU0VpanlkdXVQQmJ2WU5peno4QWNLTDVxZTlhSHI3NnNiM0k0Y3JkQ0xaOU05bGtsQl8zQklvaktWSDZ4aVp2MHlYelJuUDJyTU9CZC1OZjJxNFc1dDcwSUlxaVh1LTMyWWFwU0IwUU9kOUFpMWpnOERtLTh1VmJiNGVwcXBMbU5fMjVZc0hFbmxQT2puSFd1ZGpyTkphLU5sVlBZWWxrWEZrWGJQWmVkN19tZFZfZ1l1V3pSWlA0V0ZxM2lrWnl2NU9WeTdCbDROSmhfeENKTFhMVXk1d195S2JMUFJoRXZjcVo4V2g0MTNKRnZhUE1wRkNPM3FZOGdVazJPeW5PSGpuZnFGTTdJMkRnam5rUlV6NFlqODlIelRYaEN5VjdJNnVwbllNODNCTFRHMWlXbmM1VlRxbXB3Wm9LRjVrQUpjYzRNMThUMWwwSVhBMUlyamtPZnE4R0o4bEdHay1zMjR5RDJkZ1lYRHZaNHVHU2otR3ZpN25LZlEySEU0UmdTNzJGVHNWQXMyb0dVMV9WUE13ODhZWUFaakxGOWZieGNXZkNYRnV5djEyWTZLcmdrajRBLU1rS1Z0VVRkOWlDMU9fMGVmYXFhZXJGMUhpNkdmb2hkbzZ1OWV6VlNmVzNISjVYTFh6SjJNdWR5MWZidE8yVEo2dnRrZXhMRXBPczUwTG13OGhNUVpIQm0zQmRKRnJ0Nl8wNW1Ob0dHRDVpU0NWREV3TkY2SjktdVBkMFU1ZXBmSFpHQ3FHNTRZdTJvaExpZVEtLTU4YTVyeFBpNDdEajZtWUc4c1dBeUJqQ3NIY1NLS0FIMUxGZzZxNFNkOG9ORGNHWWJCVnZuNnJVTEtoQi1mRTZyUl81ZWJJMi1KOGdERzBhNVRZeHRYUUlqY2JvMFlaNHhWMU9pWFFiZjdaLUhkaG15TTBPZVlkS2R5UVdENTI4QVFiY1RJV0ZNZnlpVWxfZmlnN1BXbGdrbjFGUkhzYl9qeHBxVVJacUE4bjZETENHVFpSamh0NVpOM2hMYTZjYzBuS3J0a3hhZGxSM1V5UHd2OTU3ZHY0Yy1xWDBkWUk0Ymp0MWVrS3YzSktKODhQZnY3QTZ1Wm1VZkZJbS1jamdreks1ZlhpQjFOUDFiOHJ2Nm9NcmdTdU5LQXV2RkZWZEFNZnVKUjVwcVY3dDdhQnpmRVJ6SmlvVXpDM0ZiYXh5bGE2X04tTE9qZ3BiTnN3TF9ZaFRxSUpjNjB1dXZBcy1TZHRHTjFjSUR3WUl4cE9VNzB5Rkk4U3Z1SVZYTl9sYXlZVk83UnFrMlVmcnBpam9lRUlCY19DdVJwOXl2TVVDV1pMRFZTZk9MY3Z1eXA0MnhGazc5YllQaWtOeTc4NjlOa2lGY05RRzY1cG9nbGpYelc4c3FicWxWRkg0YzRSamFlQ19zOU14YWJreU9pNDREZVJ3a0REMUxGTzF1XzI1bEF3VXVZRjlBeWFiLXJsOXgza3VZem1WckhWSnVNbDBNcldadU8xQ3RwOTl5NGgtVlR0QklCLWl5WkE4V1FlQTBCOVU1RE9sQlRrYUNZOGdfUmEwbEZvUTFGUEFWVmQ4V1FhOU9VNjZqemRpZm1sUDhZQTJ0YVBRbWZldkF5THV4QXpfdUtNZ0tlcGdSRFM3c0lDOTNQbnBxdmxYYWNpTmI3MW9BMlZIdTQ5RldudHpNQWQ5NDNPLVVTLXVVNzdHZXh4UXpZa3dVa2J4dTFDV1RkYjRnWXU2M3lJekRYWGNMcWU5OVh6U2xZWDh6MmpqcnpiOHlnMjA5S3RFQm1NZjNSM21adkVnTUpSYVhkTzNkNnJCTmljY0x1cl9kMkx3UHhySjZEdHREanZERzNEUTFlTkR0NWlBczAtdmFGTjdZNVpTMlkxV2czYW5RN2lqemg4eUViZDV6RjdKNXdFcUlvcVhoNkJ6eVJkR1pua1hnNzQwOEs2TXJYSlpGcW9qRDU2QjBOWFFtdXBJRkRKbmdZUF9ZSmRPVEtvUjVhLTV1NjdXQjRhS0duaEtJb2FrQnNjUTRvdFMxdkdTNk1NYlFHUFhhYTJ1eUN3WHN4UlJ4UjdrZjY0SzFGYWVFN1k0cGJnc1RjNmFUenR4NHljbVhablZSWHZmUVN3cXRHNjhsX1BSZWEzdTJUZFA0S2pTaU9YMnZIQ1ZPcGhWMFJqZkVEMWRMR1h3SnU0Z2FzZ3VGM3puNzdhVjhaQXNIWHFsbjB0TDVYSFdSNV9rdWhUUUhSZHBGYkJIVDB5SDdlMC13QTVnS0g5Qkg5RGNxSGJlelVndUhPcEQ0QkRKMTJTZUM1OXJhVm0zYjU0OVY2dk9MQVBheklIQXpVNW9Yc0ROVjEzaFZTWmVxYlBWMlNlSzladzJ6TmNuMG5FVVZkN1VZN1pfS2ZHa0lQcE80S24wSnQtVlJVV09OVWJ3M09YMkZpV2ktVF9ENHhKU2dfYUQ2aUVyamk0VHJHQmVfVHU4clpUTFoteW5aSWRPV1M0RDRMTms4NGRoYmJfVE82aUl2X3VieVJOdDhBQmRwdzdnRTVBNzZwaW93dUlZb3ZRYUtOeG9ULWxvNVp5a0haSjdkcUhRb3d6UGIxRUpCVkVYX2d6TkRqQVozUWxkNGFoc1FXYVd2YWNkME9Qclo0bjYxMFRWTy1nbnI5NTBJNzRMMDluUXRKYTFqQUN4d0d5aHVlamN3Tkk3NWJXeXR0TW9BeUg5Vnp4Q2RnZUY3b3AtMDlrNmlrSGR0eGRtbUdUd2lFRWg4MklEeWJHN2wwZEpVSXMxNDNOWjRFS0tPdWxhMmFCckhfRENIY184aEFDZXNrRDl2dHQtQW12UnRuQXJjaDJoTUpiYkNWQUtfRG9GMUZoNWM4UnBYZ29RWWs2NHcyUm5kdTF3Vk1GeFpiRUJLaVZ2UGFjbi1jV3lMV0N2ZDl4VERPN295X01NNG56ZjZkRzZoYUtmY1E5NlVXemx2SnVfb19iSXg0R2M3Mjd1a2JRPT0= Connector_GoogleSpeech_API_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z4NFQxaF9uN3h1cVB6dnZid1c1R1VfNDlSQ1NHMEVDZWtKanpMQ29CLXc1MXBqRm1hQ0YtWVhaejBMY1ZTOEFEVlpWQ3hrYkFza1E2RDNsYkdMMndNR0VGNTMwVDRGdURJY3hyaVFxVjEtSEYwNHJzeWM3WmlpZW9jU2E3NTgycEV2allqQ3dJRTNyRFAzaDJ6dklKeXpNRkJhYjFzUkptN2dpbkNpMklrcGxuZl9vTkt3T0JvNm1YTXd5UlkwZWptUXdWVFpnV2J4X3J2WUhIUlFkSElFVnlqMnlJRnNHTnlpMWs2R1dZc2ROWjNYZG85cndmd1E5cUZnVmZRYnVjTG43dXFmSWd2bGFfVWFWSmtpWkpndWNlSUNwcnFNU2NqZXFaV0xsY3l3SElLRkVHcHZGZERKV1ltcGhTS0dhTko1VTJLYzNoZjRkSGVEX3dTMWVVTmdDczV5cE1JQUdSbUJGUm11eFhTVjJHbkt0SzB4UG1Dc2xmbnp1Y041Y2RTeWRuWGdmQy1sTGx0MGtnM2VJQ3EyLXViRlNhTU9ybzZkR1N1bXE5SXhlZENWRFpWSGlYOWx4SUQ3UlR0ZEVxQkxNakRUVFRiUmFnbklOalphLUZkRFVVaXBRUk5NZW5PaUZydTFmQkNPSTdTVTNZd0plWXllNVFJdmN4MVcyTGlwMGFtVjBzOGRxR1FjbzhfYW5zdTB0ZEZBTTJhakltazh1dktNMUZsOUItdFdTb1pIaUxySllXNkdlY20zUS0wTnpFNTB2SU5acG1VcXhyaHBmME8takw3RDh5T043T2VGOV92TzNya2pWSlpYVjZDdXlZcjM3a0hPTlhkaW9oQmxqQlpGRFYyTTY4WmZmT3k4Tk1tdXRuSGdTUVpNT2NKenhXb05PdXBfSEdhMTNxNjdpNXlKUUI2YUgydFFPX1VvXzVJb0UxWTU2YVNiNDQ0QndZanhMMHR1cGdHWGhvcEg1QXEtSXZJdTdZUE12ZEVVWkF4QmtsQS1GYnY3SFIxSHlsOGVfcEpGS1A4QUVEQWNEOFZYYlljQ3ByTU03YU16Y0UzUnJQZEprSWNjT1ZXVEtDWi03Y3ZzRVdYUTlabXJISEo5THRHVXVuM0xqbzA4bGVlZVpOMk1QMmptb21tV0pTMlVoOXdWVU95UW1iQmttc2w1RG9mMWwxXzg1T2IxYUVmTUJEZkpUdTFDTzZ3RlBFeUFiX01iRTZNWkNaSG45TkFOM2pzbUJRZ2N0VFpoejJUTG1RODY3TzZpSzVkYUQzaEpfY2pSTkRzU0VpanlkdXVQQmJ2WU5peno4QWNLTDVxZTlhSHI3NnNiM0k0Y3JkQ0xaOU05bGtsQl8zQklvaktWSDZ4aVp2MHlYelJuUDJyTU9CZC1OZjJxNFc1dDcwSUlxaVh1LTMyWWFwU0IwUU9kOUFpMWpnOERtLTh1VmJiNGVwcXBMbU5fMjVZc0hFbmxQT2puSFd1ZGpyTkphLU5sVlBZWWxrWEZrWGJQWmVkN19tZFZfZ1l1V3pSWlA0V0ZxM2lrWnl2NU9WeTdCbDROSmhfeENKTFhMVXk1d195S2JMUFJoRXZjcVo4V2g0MTNKRnZhUE1wRkNPM3FZOGdVazJPeW5PSGpuZnFGTTdJMkRnam5rUlV6NFlqODlIelRYaEN5VjdJNnVwbllNODNCTFRHMWlXbmM1VlRxbXB3Wm9LRjVrQUpjYzRNMThUMWwwSVhBMUlyamtPZnE4R0o4bEdHay1zMjR5RDJkZ1lYRHZaNHVHU2otR3ZpN25LZlEySEU0UmdTNzJGVHNWQXMyb0dVMV9WUE13ODhZWUFaakxGOWZieGNXZkNYRnV5djEyWTZLcmdrajRBLU1rS1Z0VVRkOWlDMU9fMGVmYXFhZXJGMUhpNkdmb2hkbzZ1OWV6VlNmVzNISjVYTFh6SjJNdWR5MWZidE8yVEo2dnRrZXhMRXBPczUwTG13OGhNUVpIQm0zQmRKRnJ0Nl8wNW1Ob0dHRDVpU0NWREV3TkY2SjktdVBkMFU1ZXBmSFpHQ3FHNTRZdTJvaExpZVEtLTU4YTVyeFBpNDdEajZtWUc4c1dBeUJqQ3NIY1NLS0FIMUxGZzZxNFNkOG9ORGNHWWJCVnZuNnJVTEtoQi1mRTZyUl81ZWJJMi1KOGdERzBhNVRZeHRYUUlqY2JvMFlaNHhWMU9pWFFiZjdaLUhkaG15TTBPZVlkS2R5UVdENTI4QVFiY1RJV0ZNZnlpVWxfZmlnN1BXbGdrbjFGUkhzYl9qeHBxVVJacUE4bjZETENHVFpSamh0NVpOM2hMYTZjYzBuS3J0a3hhZGxSM1V5UHd2OTU3ZHY0Yy1xWDBkWUk0Ymp0MWVrS3YzSktKODhQZnY3QTZ1Wm1VZkZJbS1jamdreks1ZlhpQjFOUDFiOHJ2Nm9NcmdTdU5LQXV2RkZWZEFNZnVKUjVwcVY3dDdhQnpmRVJ6SmlvVXpDM0ZiYXh5bGE2X04tTE9qZ3BiTnN3TF9ZaFRxSUpjNjB1dXZBcy1TZHRHTjFjSUR3WUl4cE9VNzB5Rkk4U3Z1SVZYTl9sYXlZVk83UnFrMlVmcnBpam9lRUlCY19DdVJwOXl2TVVDV1pMRFZTZk9MY3Z1eXA0MnhGazc5YllQaWtOeTc4NjlOa2lGY05RRzY1cG9nbGpYelc4c3FicWxWRkg0YzRSamFlQ19zOU14YWJreU9pNDREZVJ3a0REMUxGTzF1XzI1bEF3VXVZRjlBeWFiLXJsOXgza3VZem1WckhWSnVNbDBNcldadU8xQ3RwOTl5NGgtVlR0QklCLWl5WkE4V1FlQTBCOVU1RE9sQlRrYUNZOGdfUmEwbEZvUTFGUEFWVmQ4V1FhOU9VNjZqemRpZm1sUDhZQTJ0YVBRbWZldkF5THV4QXpfdUtNZ0tlcGdSRFM3c0lDOTNQbnBxdmxYYWNpTmI3MW9BMlZIdTQ5RldudHpNQWQ5NDNPLVVTLXVVNzdHZXh4UXpZa3dVa2J4dTFDV1RkYjRnWXU2M3lJekRYWGNMcWU5OVh6U2xZWDh6MmpqcnpiOHlnMjA5S3RFQm1NZjNSM21adkVnTUpSYVhkTzNkNnJCTmljY0x1cl9kMkx3UHhySjZEdHREanZERzNEUTFlTkR0NWlBczAtdmFGTjdZNVpTMlkxV2czYW5RN2lqemg4eUViZDV6RjdKNXdFcUlvcVhoNkJ6eVJkR1pua1hnNzQwOEs2TXJYSlpGcW9qRDU2QjBOWFFtdXBJRkRKbmdZUF9ZSmRPVEtvUjVhLTV1NjdXQjRhS0duaEtJb2FrQnNjUTRvdFMxdkdTNk1NYlFHUFhhYTJ1eUN3WHN4UlJ4UjdrZjY0SzFGYWVFN1k0cGJnc1RjNmFUenR4NHljbVhablZSWHZmUVN3cXRHNjhsX1BSZWEzdTJUZFA0S2pTaU9YMnZIQ1ZPcGhWMFJqZkVEMWRMR1h3SnU0Z2FzZ3VGM3puNzdhVjhaQXNIWHFsbjB0TDVYSFdSNV9rdWhUUUhSZHBGYkJIVDB5SDdlMC13QTVnS0g5Qkg5RGNxSGJlelVndUhPcEQ0QkRKMTJTZUM1OXJhVm0zYjU0OVY2dk9MQVBheklIQXpVNW9Yc0ROVjEzaFZTWmVxYlBWMlNlSzladzJ6TmNuMG5FVVZkN1VZN1pfS2ZHa0lQcE80S24wSnQtVlJVV09OVWJ3M09YMkZpV2ktVF9ENHhKU2dfYUQ2aUVyamk0VHJHQmVfVHU4clpUTFoteW5aSWRPV1M0RDRMTms4NGRoYmJfVE82aUl2X3VieVJOdDhBQmRwdzdnRTVBNzZwaW93dUlZb3ZRYUtOeG9ULWxvNVp5a0haSjdkcUhRb3d6UGIxRUpCVkVYX2d6TkRqQVozUWxkNGFoc1FXYVd2YWNkME9Qclo0bjYxMFRWTy1nbnI5NTBJNzRMMDluUXRKYTFqQUN4d0d5aHVlamN3Tkk3NWJXeXR0TW9BeUg5Vnp4Q2RnZUY3b3AtMDlrNmlrSGR0eGRtbUdUd2lFRWg4MklEeWJHN2wwZEpVSXMxNDNOWjRFS0tPdWxhMmFCckhfRENIY184aEFDZXNrRDl2dHQtQW12UnRuQXJjaDJoTUpiYkNWQUtfRG9GMUZoNWM4UnBYZ29RWWs2NHcyUm5kdTF3Vk1GeFpiRUJLaVZ2UGFjbi1jV3lMV0N2ZDl4VERPN295X01NNG56ZjZkRzZoYUtmY1E5NlVXemx2SnVfb19iSXg0R2M3Mjd1a2JRPT0=

View file

@ -23,7 +23,7 @@ from modules.shared.configuration import APP_CONFIG
from modules.security.rootAccess import getRootDbAppConnector, getRootUser from modules.security.rootAccess import getRootDbAppConnector, getRootUser
from modules.interfaces.interfaceDbApp import getInterface, getRootInterface from modules.interfaces.interfaceDbApp import getInterface, getRootInterface
from modules.datamodels.datamodelUam import User, AuthAuthority, AccessLevel from modules.datamodels.datamodelUam import User, AuthAuthority, AccessLevel
from modules.datamodels.datamodelSecurity import Token from modules.datamodels.datamodelSecurity import Token, TokenPurpose
from modules.datamodels.datamodelRbac import AccessRule from modules.datamodels.datamodelRbac import AccessRule
# Get Config Data # Get Config Data
@ -189,10 +189,46 @@ def _getUserBase(token: str = Depends(cookieAuth)) -> User:
f"Local JWT db record not active/valid: jti={tokenId}, userId={user.id}, sessionId={sessionId}" f"Local JWT db record not active/valid: jti={tokenId}, userId={user.id}, sessionId={sessionId}"
) )
raise credentialsException raise credentialsException
elif token_authority == str(AuthAuthority.GOOGLE.value):
active_token = appInterface.findActiveTokenById(
tokenId=tokenId,
userId=user.id,
authority=AuthAuthority.GOOGLE,
sessionId=sessionId,
mandateId=None,
tokenPurpose=TokenPurpose.AUTH_SESSION.value,
)
if not active_token:
logger.info(
f"Google JWT db record not active/valid: jti={tokenId}, userId={user.id}"
)
raise credentialsException
elif token_authority == str(AuthAuthority.MSFT.value):
active_token = appInterface.findActiveTokenById(
tokenId=tokenId,
userId=user.id,
authority=AuthAuthority.MSFT,
sessionId=sessionId,
mandateId=None,
tokenPurpose=TokenPurpose.AUTH_SESSION.value,
)
if not active_token:
logger.info(
f"Microsoft JWT db record not active/valid: jti={tokenId}, userId={user.id}"
)
raise credentialsException
else: else:
# No DB record for this token. If the claim says local (or missing/unknown), require DB record. # No DB record for this token. If the claim says local (or missing/unknown), require DB record.
if normalized_authority in (None, "", str(AuthAuthority.LOCAL.value)): if normalized_authority in (
logger.info("Local JWT without server record or missing authority claim") None,
"",
str(AuthAuthority.LOCAL.value),
str(AuthAuthority.GOOGLE.value),
str(AuthAuthority.MSFT.value),
):
logger.info(
"JWT without server record or missing authority claim (local/google/msft require DB row)"
)
raise credentialsException raise credentialsException
except HTTPException: except HTTPException:
raise raise

View file

@ -24,10 +24,17 @@ class CSRFMiddleware(BaseHTTPMiddleware):
self.exempt_paths = exempt_paths or { self.exempt_paths = exempt_paths or {
"/api/local/login", "/api/local/login",
"/api/local/register", "/api/local/register",
"/api/msft/login", # OAuth Auth app + Data app (GET redirects / callbacks)
"/api/google/login", "/api/msft/auth/login",
"/api/msft/callback", "/api/msft/auth/login/callback",
"/api/google/callback", "/api/msft/auth/connect",
"/api/msft/auth/connect/callback",
"/api/msft/adminconsent",
"/api/msft/adminconsent/callback",
"/api/google/auth/login",
"/api/google/auth/login/callback",
"/api/google/auth/connect",
"/api/google/auth/connect/callback",
"/api/billing/webhook/stripe", # Stripe webhook (auth via Stripe-Signature) "/api/billing/webhook/stripe", # Stripe webhook (auth via Stripe-Signature)
} }

View file

@ -0,0 +1,42 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""OAuth scope sets for split Auth- vs Data-apps (Google / Microsoft)."""
# Google — Auth app only (no Gmail/Drive API scopes)
googleAuthScopes = [
"openid",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
]
# Google — Data app (Gmail + Drive + identity for token responses)
googleDataScopes = [
"openid",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/drive.readonly",
]
# Microsoft — Auth app: Graph profile only (MSAL adds openid, profile, offline_access, …)
msftAuthScopes = [
"User.Read",
]
# Microsoft — Data app (delegated; requires admin consent for several)
msftDataScopes = [
"User.Read",
"Mail.ReadWrite",
"Mail.Send",
"Files.ReadWrite.All",
"Sites.ReadWrite.All",
"Team.ReadBasic.All",
"OnlineMeetings.Read",
"Chat.ReadWrite",
"ChatMessage.Send",
]
def msftDataScopesForRefresh() -> str:
"""Space-separated scope string identical to authorization request (Token v2 refresh)."""
return " ".join(msftDataScopes)

View file

@ -9,10 +9,11 @@ import logging
import httpx import httpx
from typing import Optional, Dict, Any, Callable from typing import Optional, Dict, Any, Callable
from modules.datamodels.datamodelSecurity import Token from modules.datamodels.datamodelSecurity import Token, TokenPurpose
from modules.datamodels.datamodelUam import AuthAuthority from modules.datamodels.datamodelUam import AuthAuthority
from modules.shared.configuration import APP_CONFIG from modules.shared.configuration import APP_CONFIG
from modules.shared.timeUtils import getUtcTimestamp, createExpirationTimestamp, parseTimestamp from modules.shared.timeUtils import getUtcTimestamp, createExpirationTimestamp, parseTimestamp
from modules.auth.oauthProviderConfig import msftDataScopesForRefresh
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -20,14 +21,14 @@ class TokenManager:
"""Centralized token management service""" """Centralized token management service"""
def __init__(self): def __init__(self):
# Microsoft OAuth configuration # Microsoft Data-app OAuth (refresh + token exchange for connections)
self.msft_client_id = APP_CONFIG.get("Service_MSFT_CLIENT_ID") self.msft_client_id = APP_CONFIG.get("Service_MSFT_DATA_CLIENT_ID")
self.msft_client_secret = APP_CONFIG.get("Service_MSFT_CLIENT_SECRET") self.msft_client_secret = APP_CONFIG.get("Service_MSFT_DATA_CLIENT_SECRET")
self.msft_tenant_id = APP_CONFIG.get("Service_MSFT_TENANT_ID", "common") self.msft_tenant_id = APP_CONFIG.get("Service_MSFT_TENANT_ID", "common")
# Google OAuth configuration # Google Data-app OAuth
self.google_client_id = APP_CONFIG.get("Service_GOOGLE_CLIENT_ID") self.google_client_id = APP_CONFIG.get("Service_GOOGLE_DATA_CLIENT_ID")
self.google_client_secret = APP_CONFIG.get("Service_GOOGLE_CLIENT_SECRET") self.google_client_secret = APP_CONFIG.get("Service_GOOGLE_DATA_CLIENT_SECRET")
def refreshMicrosoftToken(self, refreshToken: str, userId: str, oldToken: Token) -> Optional[Token]: def refreshMicrosoftToken(self, refreshToken: str, userId: str, oldToken: Token) -> Optional[Token]:
"""Refresh Microsoft OAuth token using refresh token""" """Refresh Microsoft OAuth token using refresh token"""
@ -49,7 +50,7 @@ class TokenManager:
"client_secret": self.msft_client_secret, "client_secret": self.msft_client_secret,
"grant_type": "refresh_token", "grant_type": "refresh_token",
"refresh_token": refreshToken, "refresh_token": refreshToken,
"scope": "Mail.ReadWrite Mail.Send Mail.ReadWrite.Shared User.Read" "scope": msftDataScopesForRefresh(),
} }
logger.debug(f"refreshMicrosoftToken: Refresh request data prepared (refreshToken length: {len(refreshToken) if refreshToken else 0})") logger.debug(f"refreshMicrosoftToken: Refresh request data prepared (refreshToken length: {len(refreshToken) if refreshToken else 0})")
@ -68,6 +69,7 @@ class TokenManager:
userId=userId, userId=userId,
authority=AuthAuthority.MSFT, authority=AuthAuthority.MSFT,
connectionId=oldToken.connectionId, # Preserve connection ID connectionId=oldToken.connectionId, # Preserve connection ID
tokenPurpose=TokenPurpose.DATA_CONNECTION,
tokenAccess=tokenData["access_token"], tokenAccess=tokenData["access_token"],
tokenRefresh=tokenData.get("refresh_token", refreshToken), # Keep old refresh token if new one not provided tokenRefresh=tokenData.get("refresh_token", refreshToken), # Keep old refresh token if new one not provided
tokenType=tokenData.get("token_type", "bearer"), tokenType=tokenData.get("token_type", "bearer"),
@ -128,6 +130,7 @@ class TokenManager:
userId=userId, userId=userId,
authority=AuthAuthority.GOOGLE, authority=AuthAuthority.GOOGLE,
connectionId=oldToken.connectionId, # Preserve connection ID connectionId=oldToken.connectionId, # Preserve connection ID
tokenPurpose=TokenPurpose.DATA_CONNECTION,
tokenAccess=tokenData["access_token"], tokenAccess=tokenData["access_token"],
tokenRefresh=tokenData.get("refresh_token", refreshToken), # Use new refresh token if provided tokenRefresh=tokenData.get("refresh_token", refreshToken), # Use new refresh token if provided
tokenType=tokenData.get("token_type", "bearer"), tokenType=tokenData.get("token_type", "bearer"),
@ -165,6 +168,15 @@ class TokenManager:
logger.debug(f"refreshToken: Starting refresh for token {oldToken.id}, authority: {oldToken.authority}") logger.debug(f"refreshToken: Starting refresh for token {oldToken.id}, authority: {oldToken.authority}")
logger.debug(f"refreshToken: Token details: userId={oldToken.userId}, connectionId={oldToken.connectionId}, hasRefreshToken={bool(oldToken.tokenRefresh)}") logger.debug(f"refreshToken: Token details: userId={oldToken.userId}, connectionId={oldToken.connectionId}, hasRefreshToken={bool(oldToken.tokenRefresh)}")
_tp = (
oldToken.tokenPurpose.value
if isinstance(oldToken.tokenPurpose, TokenPurpose)
else oldToken.tokenPurpose
)
if _tp != TokenPurpose.DATA_CONNECTION.value:
logger.warning("refreshToken: skipped — token is not dataConnection")
return None
# Cooldown: avoid refreshing too frequently if a workflow triggers refresh repeatedly # Cooldown: avoid refreshing too frequently if a workflow triggers refresh repeatedly
# Only allow a new refresh if at least 10 minutes passed since the token was created/refreshed # Only allow a new refresh if at least 10 minutes passed since the token was created/refreshed
try: try:
@ -266,6 +278,16 @@ class TokenManager:
token = interface.getConnectionToken(connectionId) token = interface.getConnectionToken(connectionId)
if not token: if not token:
return None return None
_tp = (
token.tokenPurpose.value
if isinstance(token.tokenPurpose, TokenPurpose)
else token.tokenPurpose
)
if _tp != TokenPurpose.DATA_CONNECTION.value:
logger.warning(
f"getFreshToken: connection {connectionId} tokenPurpose is {_tp}, expected dataConnection"
)
return None
return self.ensureFreshToken( return self.ensureFreshToken(
token, token,
secondsBeforeExpiry=secondsBeforeExpiry, secondsBeforeExpiry=secondsBeforeExpiry,

View file

@ -11,11 +11,13 @@ import uuid
class BillingModelEnum(str, Enum): class BillingModelEnum(str, Enum):
"""Billing model types.""" """Billing model types (prepaid only; legacy UNLIMITED in DB maps to PREPAY_MANDATE)."""
PREPAY_MANDATE = "PREPAY_MANDATE" # Prepaid budget shared by all users in mandate PREPAY_MANDATE = "PREPAY_MANDATE" # Prepaid budget shared by all users in mandate
PREPAY_USER = "PREPAY_USER" # Prepaid budget per user within mandate PREPAY_USER = "PREPAY_USER" # Prepaid budget per user within mandate
CREDIT_POSTPAY = "CREDIT_POSTPAY" # Credit with monthly invoice (requires billing address)
UNLIMITED = "UNLIMITED" # No cost limitation (internal mandates only)
# Nur fuer initRootMandateBilling (Root-Mandant PREPAY_USER + Startguthaben in Settings).
DEFAULT_USER_CREDIT_CHF = 5.0
class AccountTypeEnum(str, Enum): class AccountTypeEnum(str, Enum):
@ -46,30 +48,6 @@ class PeriodTypeEnum(str, Enum):
YEAR = "YEAR" YEAR = "YEAR"
class BillingAddress(BaseModel):
"""Billing address for CREDIT_POSTPAY mandates."""
company: str = Field(..., description="Company name")
street: str = Field(..., description="Street and number")
zip: str = Field(..., description="Postal code")
city: str = Field(..., description="City")
country: str = Field(default="CH", description="Country code")
vatNumber: Optional[str] = Field(None, description="VAT number (optional)")
registerModelLabels(
"BillingAddress",
{"en": "Billing Address", "de": "Rechnungsadresse"},
{
"company": {"en": "Company", "de": "Firma"},
"street": {"en": "Street", "de": "Strasse"},
"zip": {"en": "ZIP", "de": "PLZ"},
"city": {"en": "City", "de": "Ort"},
"country": {"en": "Country", "de": "Land"},
"vatNumber": {"en": "VAT Number", "de": "MwSt-Nummer"},
},
)
class BillingAccount(BaseModel): class BillingAccount(BaseModel):
"""Billing account for mandate or user-mandate combination.""" """Billing account for mandate or user-mandate combination."""
id: str = Field( id: str = Field(
@ -79,7 +57,6 @@ class BillingAccount(BaseModel):
userId: Optional[str] = Field(None, description="Foreign key to User (only for PREPAY_USER)") userId: Optional[str] = Field(None, description="Foreign key to User (only for PREPAY_USER)")
accountType: AccountTypeEnum = Field(..., description="Account type: MANDATE or USER") accountType: AccountTypeEnum = Field(..., description="Account type: MANDATE or USER")
balance: float = Field(default=0.0, description="Current balance in CHF") balance: float = Field(default=0.0, description="Current balance in CHF")
creditLimit: Optional[float] = Field(None, description="Credit limit in CHF (only for CREDIT_POSTPAY)")
warningThreshold: float = Field(default=0.0, description="Warning threshold in CHF") warningThreshold: float = Field(default=0.0, description="Warning threshold in CHF")
lastWarningAt: Optional[datetime] = Field(None, description="Last warning sent timestamp") lastWarningAt: Optional[datetime] = Field(None, description="Last warning sent timestamp")
enabled: bool = Field(default=True, description="Account is active") enabled: bool = Field(default=True, description="Account is active")
@ -94,7 +71,6 @@ registerModelLabels(
"userId": {"en": "User ID", "de": "Benutzer-ID"}, "userId": {"en": "User ID", "de": "Benutzer-ID"},
"accountType": {"en": "Account Type", "de": "Kontotyp"}, "accountType": {"en": "Account Type", "de": "Kontotyp"},
"balance": {"en": "Balance (CHF)", "de": "Guthaben (CHF)"}, "balance": {"en": "Balance (CHF)", "de": "Guthaben (CHF)"},
"creditLimit": {"en": "Credit Limit (CHF)", "de": "Kreditlimit (CHF)"},
"warningThreshold": {"en": "Warning Threshold (CHF)", "de": "Warnschwelle (CHF)"}, "warningThreshold": {"en": "Warning Threshold (CHF)", "de": "Warnschwelle (CHF)"},
"lastWarningAt": {"en": "Last Warning", "de": "Letzte Warnung"}, "lastWarningAt": {"en": "Last Warning", "de": "Letzte Warnung"},
"enabled": {"en": "Enabled", "de": "Aktiv"}, "enabled": {"en": "Enabled", "de": "Aktiv"},
@ -161,15 +137,17 @@ class BillingSettings(BaseModel):
billingModel: BillingModelEnum = Field(..., description="Billing model") billingModel: BillingModelEnum = Field(..., description="Billing model")
# Configuration # Configuration
defaultUserCredit: float = Field(default=10.0, description="Initial credit in CHF for new users (PREPAY_USER)") defaultUserCredit: float = Field(
default=0.0,
description="Automatic initial credit (CHF) for PREPAY_USER only when a user is newly added to the root mandate; other mandates use 0 on join.",
)
warningThresholdPercent: float = Field(default=10.0, description="Warning threshold as percentage") warningThresholdPercent: float = Field(default=10.0, description="Warning threshold as percentage")
blockOnZeroBalance: bool = Field(default=True, description="Block AI features when balance is zero")
# Billing address (required for CREDIT_POSTPAY) # Notifications (e.g. mandate owner / finance — also used when PREPAY_MANDATE pool is exhausted)
billingAddress: Optional[BillingAddress] = Field(None, description="Billing address") notifyEmails: List[str] = Field(
default_factory=list,
# Notifications description="Email addresses for billing alerts (mandate pool exhausted, warnings, etc.)",
notifyEmails: List[str] = Field(default_factory=list, description="Email addresses for billing notifications") )
notifyOnWarning: bool = Field(default=True, description="Send email when warning threshold is reached") notifyOnWarning: bool = Field(default=True, description="Send email when warning threshold is reached")
@ -180,11 +158,15 @@ registerModelLabels(
"id": {"en": "ID", "de": "ID"}, "id": {"en": "ID", "de": "ID"},
"mandateId": {"en": "Mandate ID", "de": "Mandanten-ID"}, "mandateId": {"en": "Mandate ID", "de": "Mandanten-ID"},
"billingModel": {"en": "Billing Model", "de": "Abrechnungsmodell"}, "billingModel": {"en": "Billing Model", "de": "Abrechnungsmodell"},
"defaultUserCredit": {"en": "Default User Credit (CHF)", "de": "Standard-Startguthaben (CHF)"}, "defaultUserCredit": {
"en": "Root start credit (CHF)",
"de": "Startguthaben nur Root-Mandant (CHF)",
},
"warningThresholdPercent": {"en": "Warning Threshold (%)", "de": "Warnschwelle (%)"}, "warningThresholdPercent": {"en": "Warning Threshold (%)", "de": "Warnschwelle (%)"},
"blockOnZeroBalance": {"en": "Block on Zero Balance", "de": "Bei 0 blockieren"}, "notifyEmails": {
"billingAddress": {"en": "Billing Address", "de": "Rechnungsadresse"}, "en": "Billing notification emails (owner / admin)",
"notifyEmails": {"en": "Notification Emails", "de": "Benachrichtigungs-Emails"}, "de": "E-Mails für Billing-Alerts (Inhaber/Admin)",
},
"notifyOnWarning": {"en": "Notify on Warning", "de": "Bei Warnung benachrichtigen"}, "notifyOnWarning": {"en": "Notify on Warning", "de": "Bei Warnung benachrichtigen"},
}, },
) )
@ -257,7 +239,6 @@ class BillingBalanceResponse(BaseModel):
currency: str = "CHF" currency: str = "CHF"
warningThreshold: float warningThreshold: float
isWarning: bool isWarning: bool
creditLimit: Optional[float] = None
class BillingStatisticsChartData(BaseModel): class BillingStatisticsChartData(BaseModel):
@ -285,3 +266,16 @@ class BillingCheckResult(BaseModel):
currentBalance: Optional[float] = None currentBalance: Optional[float] = None
requiredAmount: Optional[float] = None requiredAmount: Optional[float] = None
billingModel: Optional[BillingModelEnum] = None billingModel: Optional[BillingModelEnum] = None
def parseBillingModelFromStoredValue(raw: Optional[str]) -> BillingModelEnum:
"""Map DB string to enum. Legacy UNLIMITED / unknown values become PREPAY_MANDATE."""
if raw is None or (isinstance(raw, str) and raw.strip() == ""):
return BillingModelEnum.PREPAY_MANDATE
s = str(raw).strip().upper()
if s == "UNLIMITED":
return BillingModelEnum.PREPAY_MANDATE
try:
return BillingModelEnum(raw)
except ValueError:
return BillingModelEnum.PREPAY_MANDATE

View file

@ -9,8 +9,8 @@ Multi-Tenant Design:
- Mandant-Kontext wird per Request-Header (X-Mandate-Id) bestimmt - Mandant-Kontext wird per Request-Header (X-Mandate-Id) bestimmt
""" """
from typing import Optional from typing import Optional, Any
from pydantic import BaseModel, Field, ConfigDict from pydantic import BaseModel, Field, ConfigDict, model_validator
from modules.shared.attributeUtils import registerModelLabels from modules.shared.attributeUtils import registerModelLabels
from modules.shared.timeUtils import getUtcTimestamp from modules.shared.timeUtils import getUtcTimestamp
from .datamodelUam import AuthAuthority from .datamodelUam import AuthAuthority
@ -23,6 +23,13 @@ class TokenStatus(str, Enum):
REVOKED = "revoked" REVOKED = "revoked"
class TokenPurpose(str, Enum):
"""Login/session token vs provider token bound to a UserConnection."""
AUTH_SESSION = "authSession"
DATA_CONNECTION = "dataConnection"
class Token(BaseModel): class Token(BaseModel):
""" """
Authentication Token model. Authentication Token model.
@ -38,6 +45,10 @@ class Token(BaseModel):
connectionId: Optional[str] = Field( connectionId: Optional[str] = Field(
None, description="ID of the connection this token belongs to" None, description="ID of the connection this token belongs to"
) )
tokenPurpose: Optional[TokenPurpose] = Field(
default=None,
description="authSession = gateway login JWT; dataConnection = provider OAuth for a connection",
)
tokenAccess: str tokenAccess: str
tokenType: str = "bearer" tokenType: str = "bearer"
expiresAt: float = Field( expiresAt: float = Field(
@ -65,6 +76,22 @@ class Token(BaseModel):
model_config = ConfigDict(use_enum_values=True) model_config = ConfigDict(use_enum_values=True)
@model_validator(mode="before")
@classmethod
def _defaultTokenPurposeFromDb(cls, data: Any) -> Any:
"""Missing tokenPurpose: connection rows → dataConnection; session rows → authSession."""
if isinstance(data, dict):
tp = data.get("tokenPurpose")
if tp is None or tp == "":
cid = data.get("connectionId")
purpose = (
TokenPurpose.DATA_CONNECTION.value
if cid
else TokenPurpose.AUTH_SESSION.value
)
data = {**data, "tokenPurpose": purpose}
return data
registerModelLabels( registerModelLabels(
"Token", "Token",
@ -74,6 +101,7 @@ registerModelLabels(
"userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"}, "userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"},
"authority": {"en": "Authority", "de": "Autorität", "fr": "Autorité"}, "authority": {"en": "Authority", "de": "Autorität", "fr": "Autorité"},
"connectionId": {"en": "Connection ID", "de": "Verbindungs-ID", "fr": "ID de connexion"}, "connectionId": {"en": "Connection ID", "de": "Verbindungs-ID", "fr": "ID de connexion"},
"tokenPurpose": {"en": "Token purpose", "de": "Token-Verwendung", "fr": "Usage du jeton"},
"tokenAccess": {"en": "Access Token", "de": "Zugriffstoken", "fr": "Jeton d'accès"}, "tokenAccess": {"en": "Access Token", "de": "Zugriffstoken", "fr": "Jeton d'accès"},
"tokenType": {"en": "Token Type", "de": "Token-Typ", "fr": "Type de jeton"}, "tokenType": {"en": "Token Type", "de": "Token-Typ", "fr": "Type de jeton"},
"expiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"}, "expiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"},

View file

@ -1221,10 +1221,28 @@ def _preflight_billing_check(services, mandateId: str, featureInstanceId: Option
try: try:
balanceCheck = billingService.checkBalance(0.01) balanceCheck = billingService.checkBalance(0.01)
if not balanceCheck.allowed: if not balanceCheck.allowed:
raise BillingService.InsufficientBalanceException( mid = str(getattr(services, "mandateId", None) or mandateId or "")
currentBalance=balanceCheck.currentBalance or 0.0, from modules.datamodels.datamodelBilling import BillingModelEnum
requiredAmount=0.01, from modules.serviceCenter.services.serviceBilling.billingExhaustedNotify import (
message=f"Ungenuegendes Guthaben. Aktuell: CHF {balanceCheck.currentBalance:.2f}" maybeEmailMandatePoolExhausted,
)
if balanceCheck.billingModel == BillingModelEnum.PREPAY_MANDATE:
u = getattr(services, "user", None)
ulabel = (
(getattr(u, "email", None) or getattr(u, "username", None) or str(getattr(u, "id", "")))
if u is not None else ""
)
maybeEmailMandatePoolExhausted(
mid,
str(getattr(u, "id", "") if u is not None else ""),
ulabel,
float(balanceCheck.currentBalance or 0.0),
0.01,
)
raise BillingService.InsufficientBalanceException.fromBalanceCheck(
balanceCheck,
mid,
0.01,
) )
rbacAllowedProviders = billingService.getallowedProviders() rbacAllowedProviders = billingService.getallowedProviders()
if not rbacAllowedProviders: if not rbacAllowedProviders:

View file

@ -110,14 +110,15 @@ TEMPLATE_ROLES = [
{ {
"roleLabel": "workspace-admin", "roleLabel": "workspace-admin",
"description": { "description": {
"en": "Workspace Admin - Full access to AI workspace", "en": "Workspace Admin - All UI and API actions; data is always scoped to own records (same privacy as users)",
"de": "Workspace Admin - Vollzugriff auf AI Workspace", "de": "Workspace Admin - Alle UI- und API-Aktionen; Daten immer nur eigene Datensätze (gleiche Privatsphäre wie User)",
"fr": "Administrateur Workspace - Acces complet au workspace AI" "fr": "Administrateur Workspace - Toute l'UI et les API; donnees limitees a ses propres enregistrements"
}, },
"accessRules": [ "accessRules": [
{"context": "UI", "item": None, "view": True}, {"context": "UI", "item": None, "view": True},
{"context": "RESOURCE", "item": None, "view": True}, {"context": "RESOURCE", "item": None, "view": True},
{"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"}, # DATA: never ALL in shared instances — every role (including admin) sees only _createdBy = self
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"},
] ]
}, },
] ]

View file

@ -15,6 +15,9 @@ from fastapi.responses import StreamingResponse, JSONResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from modules.auth import limiter, getRequestContext, RequestContext from modules.auth import limiter, getRequestContext, RequestContext
from modules.serviceCenter.services.serviceBilling.mainServiceBilling import (
InsufficientBalanceException,
)
from modules.interfaces import interfaceDbChat, interfaceDbManagement from modules.interfaces import interfaceDbChat, interfaceDbManagement
from modules.interfaces.interfaceAiObjects import AiObjects from modules.interfaces.interfaceAiObjects import AiObjects
from modules.serviceCenter.core.serviceStreaming import get_event_manager from modules.serviceCenter.core.serviceStreaming import get_event_manager
@ -581,6 +584,15 @@ async def _runWorkspaceAgent(
}) })
except Exception as e: except Exception as e:
if isinstance(e, InsufficientBalanceException):
logger.warning(f"Workspace blocked by billing: {e.message}")
await eventManager.emit_event(queueId, "error", {
"type": "error",
"content": e.message,
"workflowId": workflowId,
"item": e.toClientDict(),
})
else:
logger.error(f"Workspace agent error: {e}", exc_info=True) logger.error(f"Workspace agent error: {e}", exc_info=True)
await eventManager.emit_event(queueId, "error", { await eventManager.emit_event(queueId, "error", {
"type": "error", "type": "error",

View file

@ -1962,6 +1962,8 @@ def _createStoreResourceRules(db: DatabaseConnector) -> None:
storeResources = [ storeResources = [
"resource.store.automation", "resource.store.automation",
"resource.store.teamsbot", "resource.store.teamsbot",
"resource.store.workspace",
"resource.store.commcoach",
] ]
storeRules = [] storeRules = []
@ -1998,7 +2000,7 @@ def _createStoreResourceRules(db: DatabaseConnector) -> None:
def initRootMandateBilling(mandateId: str) -> None: def initRootMandateBilling(mandateId: str) -> None:
""" """
Initialize billing settings for root mandate. Initialize billing settings for root mandate.
Root mandate uses PREPAY_USER model with 10 CHF initial credit per user. Root mandate uses PREPAY_USER model with default initial credit per user in settings (DEFAULT_USER_CREDIT_CHF at bootstrap only).
Creates billing accounts for ALL users regardless of billing model (for audit trail). Creates billing accounts for ALL users regardless of billing model (for audit trail).
Args: Args:
@ -2007,7 +2009,12 @@ def initRootMandateBilling(mandateId: str) -> None:
try: try:
from modules.interfaces.interfaceDbBilling import _getRootInterface from modules.interfaces.interfaceDbBilling import _getRootInterface
from modules.interfaces.interfaceDbApp import getRootInterface as getAppRootInterface from modules.interfaces.interfaceDbApp import getRootInterface as getAppRootInterface
from modules.datamodels.datamodelBilling import BillingSettings, BillingModelEnum from modules.datamodels.datamodelBilling import (
BillingSettings,
BillingModelEnum,
DEFAULT_USER_CREDIT_CHF,
parseBillingModelFromStoredValue,
)
billingInterface = _getRootInterface() billingInterface = _getRootInterface()
appInterface = getAppRootInterface() appInterface = getAppRootInterface()
@ -2020,27 +2027,28 @@ def initRootMandateBilling(mandateId: str) -> None:
settings = BillingSettings( settings = BillingSettings(
mandateId=mandateId, mandateId=mandateId,
billingModel=BillingModelEnum.PREPAY_USER, billingModel=BillingModelEnum.PREPAY_USER,
defaultUserCredit=10.0, defaultUserCredit=DEFAULT_USER_CREDIT_CHF,
warningThresholdPercent=10.0, warningThresholdPercent=10.0,
blockOnZeroBalance=True,
notifyOnWarning=True notifyOnWarning=True
) )
billingInterface.createSettings(settings) billingInterface.createSettings(settings)
logger.info(f"Created billing settings for root mandate: PREPAY_USER with 10 CHF default credit") logger.info(
f"Created billing settings for root mandate: PREPAY_USER with {DEFAULT_USER_CREDIT_CHF} CHF default credit"
)
existingSettings = billingInterface.getSettings(mandateId) existingSettings = billingInterface.getSettings(mandateId)
# Always create user accounts for all users (audit trail) # Always create user accounts for all users (audit trail)
if existingSettings: if existingSettings:
billingModel = existingSettings.get("billingModel", "UNLIMITED") billingModel = parseBillingModelFromStoredValue(
if billingModel == BillingModelEnum.UNLIMITED.value: existingSettings.get("billingModel")
return # No accounts needed for UNLIMITED ).value
# Initial balance depends on billing model # Initial balance depends on billing model
if billingModel == BillingModelEnum.PREPAY_USER.value: if billingModel == BillingModelEnum.PREPAY_USER.value:
initialBalance = existingSettings.get("defaultUserCredit", 10.0) initialBalance = float(existingSettings.get("defaultUserCredit", 0.0))
else: else:
initialBalance = 0.0 # PREPAY_MANDATE / CREDIT_POSTPAY: budget on pool initialBalance = 0.0 # PREPAY_MANDATE: budget on pool account
userMandates = appInterface.getUserMandatesByMandate(mandateId) userMandates = appInterface.getUserMandatesByMandate(mandateId)
accountsCreated = 0 accountsCreated = 0

View file

@ -35,7 +35,7 @@ from modules.datamodels.datamodelRbac import (
Role, Role,
) )
from modules.datamodels.datamodelUam import AccessLevel from modules.datamodels.datamodelUam import AccessLevel
from modules.datamodels.datamodelSecurity import Token, AuthEvent, TokenStatus from modules.datamodels.datamodelSecurity import Token, AuthEvent, TokenStatus, TokenPurpose
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResult from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResult
from modules.datamodels.datamodelMembership import ( from modules.datamodels.datamodelMembership import (
UserMandate, UserMandate,
@ -687,11 +687,17 @@ class AppObjects:
externalUsername: str = None, externalUsername: str = None,
externalEmail: str = None, externalEmail: str = None,
isSysAdmin: bool = False, isSysAdmin: bool = False,
addExternalIdentityConnection: bool = True,
) -> User: ) -> User:
""" """
Create a new user. Create a new user.
Note: Role assignment is done via createUserMandate(), not via User fields. Note: Role assignment is done via createUserMandate(), not via User fields.
Args:
addExternalIdentityConnection: If True (default) and externalId/externalUsername are set,
creates a UserConnection row. OAuth login-only flows should pass False (data connection
is created separately via /auth/connect).
""" """
try: try:
# Ensure username is a string # Ensure username is a string
@ -727,8 +733,9 @@ class AppObjects:
if not createdRecord or not createdRecord.get("id"): if not createdRecord or not createdRecord.get("id"):
raise ValueError("Failed to create user record") raise ValueError("Failed to create user record")
# Add external connection if provided # Optional: mirror external IdP identity into UserConnections (data/API OAuth).
if externalId and externalUsername: # Auth-only login (Google/MSFT JWT) must NOT create a connection — see OAuth split.
if addExternalIdentityConnection and externalId and externalUsername:
self.addUserConnection( self.addUserConnection(
createdRecord["id"], createdRecord["id"],
authenticationAuthority, authenticationAuthority,
@ -746,7 +753,7 @@ class AppObjects:
# Clear cache to ensure fresh data (already done above) # Clear cache to ensure fresh data (already done above)
# Assign new user to the root mandate with system 'viewer' role # Assign new user to the root mandate with mandate-instance 'user' role (no feature instances)
userId = createdUser[0]["id"] userId = createdUser[0]["id"]
self._assignUserToRootMandate(userId) self._assignUserToRootMandate(userId)
@ -815,7 +822,7 @@ class AppObjects:
def _assignUserToRootMandate(self, userId: str) -> None: def _assignUserToRootMandate(self, userId: str) -> None:
""" """
Assign a new user to the root mandate with the mandate-instance 'viewer' role. Assign a new user to the root mandate with the mandate-instance 'user' role.
This ensures every user has a base membership in the system mandate. This ensures every user has a base membership in the system mandate.
Uses the mandate-instance role (mandateId=rootMandateId), not the global template. Uses the mandate-instance role (mandateId=rootMandateId), not the global template.
@ -839,17 +846,17 @@ class AppObjects:
logger.debug(f"User {userId} already assigned to root mandate") logger.debug(f"User {userId} already assigned to root mandate")
return return
# Find the mandate-instance 'viewer' role (bound to this mandate, not a global template) # Mandate-instance 'user' role (bound to this mandate, not a global template)
mandateViewerRoles = self.db.getRecordset( mandateUserRoles = self.db.getRecordset(
Role, Role,
recordFilter={"roleLabel": "viewer", "mandateId": rootMandateId, "featureInstanceId": None} recordFilter={"roleLabel": "user", "mandateId": rootMandateId, "featureInstanceId": None}
) )
viewerRoleId = mandateViewerRoles[0].get("id") if mandateViewerRoles else None userRoleId = mandateUserRoles[0].get("id") if mandateUserRoles else None
roleIds = [viewerRoleId] if viewerRoleId else [] roleIds = [userRoleId] if userRoleId else []
self.createUserMandate(userId, rootMandateId, roleIds) self.createUserMandate(userId, rootMandateId, roleIds)
logger.info(f"Assigned user {userId} to root mandate with viewer role") logger.info(f"Assigned user {userId} to root mandate with user role")
except Exception as e: except Exception as e:
# Log but don't fail user creation # Log but don't fail user creation
@ -1641,8 +1648,9 @@ class AppObjects:
Ensure a user has a billing account for the mandate if billing is configured. Ensure a user has a billing account for the mandate if billing is configured.
User accounts are always created for all billing models (for audit trail). User accounts are always created for all billing models (for audit trail).
Initial balance depends on billing model: Initial balance depends on billing model:
- PREPAY_USER: defaultUserCredit from settings - PREPAY_USER: defaultUserCredit from mandate BillingSettings when joining the root mandate (missing key => 0.0);
- PREPAY_MANDATE / CREDIT_POSTPAY: 0.0 (budget is on mandate pool) other mandates get 0.0.
- PREPAY_MANDATE: 0.0 on the user account (shared pool no per-user start credit)
Args: Args:
userId: User ID userId: User ID
@ -1650,7 +1658,7 @@ class AppObjects:
""" """
try: try:
from modules.interfaces.interfaceDbBilling import _getRootInterface as getBillingRootInterface from modules.interfaces.interfaceDbBilling import _getRootInterface as getBillingRootInterface
from modules.datamodels.datamodelBilling import BillingModelEnum from modules.datamodels.datamodelBilling import BillingModelEnum, parseBillingModelFromStoredValue
billingInterface = getBillingRootInterface() billingInterface = getBillingRootInterface()
settings = billingInterface.getSettings(mandateId) settings = billingInterface.getSettings(mandateId)
@ -1658,18 +1666,22 @@ class AppObjects:
if not settings: if not settings:
return # No billing configured for this mandate return # No billing configured for this mandate
billingModel = settings.get("billingModel", "UNLIMITED") billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
if billingModel == BillingModelEnum.UNLIMITED.value:
return # No accounts needed for UNLIMITED
# Initial balance depends on billing model # Initial balance depends on billing model (start credit only on root mandate for PREPAY_USER)
if billingModel == BillingModelEnum.PREPAY_USER.value: rootMandateId = self._getRootMandateId()
initialBalance = settings.get("defaultUserCredit", 10.0) isRootMandate = rootMandateId is not None and str(mandateId) == str(rootMandateId)
if billingModel == BillingModelEnum.PREPAY_USER:
initialBalance = (
float(settings.get("defaultUserCredit", 0.0))
if isRootMandate
else 0.0
)
else: else:
initialBalance = 0.0 # PREPAY_MANDATE / CREDIT_POSTPAY: budget is on pool initialBalance = 0.0 # PREPAY_MANDATE: budget is on pool
billingInterface.getOrCreateUserAccount(mandateId, userId, initialBalance=initialBalance) billingInterface.getOrCreateUserAccount(mandateId, userId, initialBalance=initialBalance)
logger.info(f"Ensured billing account for user {userId} in mandate {mandateId} (model={billingModel}, initial={initialBalance} CHF)") logger.info(f"Ensured billing account for user {userId} in mandate {mandateId} (model={billingModel.value}, initial={initialBalance} CHF)")
except Exception as e: except Exception as e:
logger.warning(f"Failed to create billing account for user {userId} (non-critical): {e}") logger.warning(f"Failed to create billing account for user {userId} (non-critical): {e}")
@ -1678,6 +1690,8 @@ class AppObjects:
""" """
Delete a UserMandate record (remove user from mandate). Delete a UserMandate record (remove user from mandate).
CASCADE will delete UserMandateRole entries. CASCADE will delete UserMandateRole entries.
Also removes FeatureAccess rows for any feature instances that belong to this mandate
(FeatureAccessRole rows cascade from FeatureAccess).
Args: Args:
userId: User ID userId: User ID
@ -1691,6 +1705,24 @@ class AppObjects:
if not existing: if not existing:
return False return False
# Drop feature-instance memberships for instances under this mandate
instanceRows = self.db.getRecordset(
FeatureInstance,
recordFilter={"mandateId": mandateId}
)
for row in instanceRows:
instId = row.get("id")
if not instId:
continue
accessRows = self.db.getRecordset(
FeatureAccess,
recordFilter={"userId": userId, "featureInstanceId": instId}
)
for acc in accessRows:
accId = acc.get("id")
if accId:
self.db.recordDelete(FeatureAccess, accId)
return self.db.recordDelete(UserMandate, existing.id) return self.db.recordDelete(UserMandate, existing.id)
except Exception as e: except Exception as e:
logger.error(f"Error deleting UserMandate: {e}") logger.error(f"Error deleting UserMandate: {e}")
@ -2544,6 +2576,16 @@ class AppObjects:
"Access tokens cannot have connectionId - use saveConnectionToken instead" "Access tokens cannot have connectionId - use saveConnectionToken instead"
) )
_tp = (
token.tokenPurpose.value
if isinstance(token.tokenPurpose, TokenPurpose)
else token.tokenPurpose
)
if _tp != TokenPurpose.AUTH_SESSION.value:
raise ValueError(
"saveAccessToken requires tokenPurpose=authSession (gateway session JWT)"
)
# Validate user context # Validate user context
if not self.currentUser or not self.currentUser.id: if not self.currentUser or not self.currentUser.id:
raise ValueError("No valid user context available for token storage") raise ValueError("No valid user context available for token storage")
@ -2566,6 +2608,7 @@ class AppObjects:
"userId": self.currentUser.id, "userId": self.currentUser.id,
"authority": token.authority, "authority": token.authority,
"connectionId": None, # Ensure we only delete access tokens "connectionId": None, # Ensure we only delete access tokens
"tokenPurpose": TokenPurpose.AUTH_SESSION.value,
}, },
) )
deleted_count = 0 deleted_count = 0
@ -2611,6 +2654,16 @@ class AppObjects:
"Connection tokens must have connectionId - use saveAccessToken instead" "Connection tokens must have connectionId - use saveAccessToken instead"
) )
_tp = (
token.tokenPurpose.value
if isinstance(token.tokenPurpose, TokenPurpose)
else token.tokenPurpose
)
if _tp != TokenPurpose.DATA_CONNECTION.value:
raise ValueError(
"saveConnectionToken requires tokenPurpose=dataConnection (provider OAuth)"
)
# Validate user context # Validate user context
if not self.currentUser or not self.currentUser.id: if not self.currentUser or not self.currentUser.id:
raise ValueError("No valid user context available for token storage") raise ValueError("No valid user context available for token storage")
@ -2748,6 +2801,7 @@ class AppObjects:
authority: AuthAuthority, authority: AuthAuthority,
sessionId: str = None, sessionId: str = None,
mandateId: str = None, mandateId: str = None,
tokenPurpose: str = None,
) -> Optional[Token]: ) -> Optional[Token]:
"""Find an active access token by its id (jti) with optional session/tenant scoping.""" """Find an active access token by its id (jti) with optional session/tenant scoping."""
try: try:
@ -2763,6 +2817,8 @@ class AppObjects:
recordFilter["sessionId"] = sessionId recordFilter["sessionId"] = sessionId
if mandateId is not None: if mandateId is not None:
recordFilter["mandateId"] = mandateId recordFilter["mandateId"] = mandateId
if tokenPurpose is not None:
recordFilter["tokenPurpose"] = tokenPurpose
tokens = self.db.getRecordset(Token, recordFilter=recordFilter) tokens = self.db.getRecordset(Token, recordFilter=recordFilter)
if not tokens: if not tokens:
return None return None
@ -3405,6 +3461,10 @@ def getInterface(currentUser: User, mandateId: Optional[str] = None) -> AppObjec
instance = AppObjects(currentUser) instance = AppObjects(currentUser)
instance.setUserContext(currentUser, mandateId=effectiveMandateId) instance.setUserContext(currentUser, mandateId=effectiveMandateId)
_gatewayInterfaces[contextKey] = instance _gatewayInterfaces[contextKey] = instance
else:
# Re-apply user on every resolve: a prior code path (e.g. legacy logout) may have
# cleared currentUser on this cached singleton; OAuth/login must not see a stale context.
_gatewayInterfaces[contextKey].setUserContext(currentUser, mandateId=effectiveMandateId)
return _gatewayInterfaces[contextKey] return _gatewayInterfaces[contextKey]

View file

@ -23,7 +23,6 @@ from modules.datamodels.datamodelBilling import (
BillingSettings, BillingSettings,
StripeWebhookEvent, StripeWebhookEvent,
UsageStatistics, UsageStatistics,
BillingAddress,
BillingModelEnum, BillingModelEnum,
AccountTypeEnum, AccountTypeEnum,
TransactionTypeEnum, TransactionTypeEnum,
@ -31,10 +30,49 @@ from modules.datamodels.datamodelBilling import (
PeriodTypeEnum, PeriodTypeEnum,
BillingBalanceResponse, BillingBalanceResponse,
BillingCheckResult, BillingCheckResult,
parseBillingModelFromStoredValue,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _getAppDatabaseConnector() -> DatabaseConnector:
"""App DB connector (same config as UserMandate reads in this module)."""
return DatabaseConnector(
dbDatabase=APP_CONFIG.get("DB_DATABASE", "poweron_app"),
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
dbPort=int(APP_CONFIG.get("DB_PORT", "5432")),
dbUser=APP_CONFIG.get("DB_USER"),
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET"),
)
def _getRootMandateIdFromAppDb(appDb: DatabaseConnector) -> Optional[str]:
"""Resolve root mandate id (name='root', isSystem=True) from app database."""
try:
rows = appDb.getRecordset(Mandate, recordFilter={"name": "root", "isSystem": True})
if rows:
rid = rows[0].get("id")
return str(rid) if rid is not None else None
except Exception as e:
logger.warning("Could not resolve root mandate id from app DB: %s", e)
return None
_cachedRootMandateId: Optional[str] = None
_rootMandateIdCacheResolved: bool = False
def _getCachedRootMandateId() -> Optional[str]:
"""Lazy-cached root mandate id (name=root, isSystem=True) for hot paths."""
global _cachedRootMandateId, _rootMandateIdCacheResolved
if not _rootMandateIdCacheResolved:
appDb = _getAppDatabaseConnector()
_cachedRootMandateId = _getRootMandateIdFromAppDb(appDb)
_rootMandateIdCacheResolved = True
return _cachedRootMandateId
# Singleton factory for BillingObjects instances # Singleton factory for BillingObjects instances
_billingInterfaces: Dict[str, "BillingObjects"] = {} _billingInterfaces: Dict[str, "BillingObjects"] = {}
@ -121,6 +159,8 @@ class BillingObjects:
""" """
Get billing settings for a mandate. Get billing settings for a mandate.
Normalizes billingModel for API (legacy UNLIMITED PREPAY_MANDATE) and persists once.
Args: Args:
mandateId: Mandate ID mandateId: Mandate ID
@ -132,7 +172,29 @@ class BillingObjects:
BillingSettings, BillingSettings,
recordFilter={"mandateId": mandateId} recordFilter={"mandateId": mandateId}
) )
return results[0] if results else None if not results:
return None
row = dict(results[0])
raw_bm = row.get("billingModel")
parsed = parseBillingModelFromStoredValue(raw_bm)
if str(raw_bm or "").strip().upper() == "UNLIMITED":
try:
self.updateSettings(
row["id"],
{"billingModel": BillingModelEnum.PREPAY_MANDATE.value},
)
logger.info(
"Migrated billing settings for mandate %s: UNLIMITED → PREPAY_MANDATE",
mandateId,
)
except Exception as mig_err:
logger.warning(
"Could not persist billing model migration for mandate %s: %s",
mandateId,
mig_err,
)
row["billingModel"] = parsed.value
return row
except Exception as e: except Exception as e:
logger.error(f"Error getting billing settings: {e}") logger.error(f"Error getting billing settings: {e}")
return None return None
@ -148,11 +210,6 @@ class BillingObjects:
Created settings dict Created settings dict
""" """
settingsDict = settings.model_dump(exclude_none=True) settingsDict = settings.model_dump(exclude_none=True)
# Handle nested BillingAddress
if settings.billingAddress:
settingsDict["billingAddress"] = settings.billingAddress.model_dump()
return self.db.recordCreate(BillingSettings, settingsDict) return self.db.recordCreate(BillingSettings, settingsDict)
def updateSettings(self, settingsId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]: def updateSettings(self, settingsId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
@ -168,7 +225,7 @@ class BillingObjects:
""" """
return self.db.recordModify(BillingSettings, settingsId, updates) return self.db.recordModify(BillingSettings, settingsId, updates)
def getOrCreateSettings(self, mandateId: str, defaultModel: BillingModelEnum = BillingModelEnum.UNLIMITED) -> Dict[str, Any]: def getOrCreateSettings(self, mandateId: str, defaultModel: BillingModelEnum = BillingModelEnum.PREPAY_MANDATE) -> Dict[str, Any]:
""" """
Get or create billing settings for a mandate. Get or create billing settings for a mandate.
@ -186,10 +243,9 @@ class BillingObjects:
settings = BillingSettings( settings = BillingSettings(
mandateId=mandateId, mandateId=mandateId,
billingModel=defaultModel, billingModel=defaultModel,
defaultUserCredit=10.0, defaultUserCredit=0.0,
warningThresholdPercent=10.0, warningThresholdPercent=10.0,
blockOnZeroBalance=True, notifyOnWarning=True,
notifyOnWarning=True
) )
return self.createSettings(settings) return self.createSettings(settings)
@ -365,7 +421,7 @@ class BillingObjects:
def ensureAllMandateSettingsExist(self) -> int: def ensureAllMandateSettingsExist(self) -> int:
""" """
Efficiently ensure all mandates have billing settings. Efficiently ensure all mandates have billing settings.
Creates default settings (PREPAY_USER) for mandates without settings. Creates default settings (PREPAY_MANDATE, 0 CHF) for mandates without settings.
Uses bulk queries to minimize database connections. Uses bulk queries to minimize database connections.
Returns: Returns:
@ -397,11 +453,10 @@ class BillingObjects:
# Create default billing settings # Create default billing settings
settings = BillingSettings( settings = BillingSettings(
mandateId=mandateId, mandateId=mandateId,
billingModel=BillingModelEnum.PREPAY_USER, billingModel=BillingModelEnum.PREPAY_MANDATE,
defaultUserCredit=10.0, defaultUserCredit=0.0,
warningThresholdPercent=10.0, warningThresholdPercent=10.0,
blockOnZeroBalance=True, notifyOnWarning=True,
notifyOnWarning=True
) )
self.createSettings(settings) self.createSettings(settings)
existingMandateIds.add(mandateId) # Track newly created existingMandateIds.add(mandateId) # Track newly created
@ -421,8 +476,8 @@ class BillingObjects:
Ensure all users across all mandates have billing accounts. Ensure all users across all mandates have billing accounts.
User accounts are always created regardless of billing model (for audit trail). User accounts are always created regardless of billing model (for audit trail).
Initial balance depends on billing model: Initial balance depends on billing model:
- PREPAY_USER: defaultUserCredit from settings - PREPAY_USER: defaultUserCredit from settings only for the root mandate; other mandates get 0.0
- PREPAY_MANDATE / CREDIT_POSTPAY: 0.0 (budget is on pool) - PREPAY_MANDATE: 0.0 (budget is on pool)
Uses bulk queries to minimize database connections. Uses bulk queries to minimize database connections.
@ -431,16 +486,23 @@ class BillingObjects:
""" """
try: try:
accountsCreated = 0 accountsCreated = 0
appDb = _getAppDatabaseConnector()
rootMandateId = _getCachedRootMandateId()
# Step 1: Get all billing settings (all models except UNLIMITED need user accounts) # Step 1: Get all billing settings (all mandates with settings get user accounts)
allSettings = self.db.getRecordset(BillingSettings) allSettings = self.db.getRecordset(BillingSettings)
billingMandates = {} # mandateId -> (billingModel, defaultCredit) billingMandates = {} # mandateId -> (billingModel, defaultCredit)
for s in allSettings: for s in allSettings:
billingModel = s.get("billingModel", BillingModelEnum.UNLIMITED.value) billingModel = parseBillingModelFromStoredValue(s.get("billingModel")).value
if billingModel == BillingModelEnum.UNLIMITED.value: mid = s.get("mandateId")
continue isRoot = rootMandateId is not None and str(mid) == str(rootMandateId)
defaultCredit = s.get("defaultUserCredit", 10.0) if billingModel == BillingModelEnum.PREPAY_USER.value else 0.0 if billingModel == BillingModelEnum.PREPAY_USER.value:
billingMandates[s.get("mandateId")] = (billingModel, defaultCredit) defaultCredit = (
float(s.get("defaultUserCredit", 0.0) or 0.0) if isRoot else 0.0
)
else:
defaultCredit = 0.0
billingMandates[mid] = (billingModel, defaultCredit)
if not billingMandates: if not billingMandates:
logger.debug("No billable mandates found, skipping account check") logger.debug("No billable mandates found, skipping account check")
@ -457,13 +519,6 @@ class BillingObjects:
existingAccountKeys.add(key) existingAccountKeys.add(key)
# Step 3: Get all user-mandate combinations from APP database # Step 3: Get all user-mandate combinations from APP database
appDb = DatabaseConnector(
dbDatabase=APP_CONFIG.get('DB_DATABASE', 'poweron_app'),
dbHost=APP_CONFIG.get('DB_HOST', 'localhost'),
dbPort=int(APP_CONFIG.get('DB_PORT', '5432')),
dbUser=APP_CONFIG.get('DB_USER'),
dbPassword=APP_CONFIG.get('DB_PASSWORD_SECRET')
)
allUserMandates = appDb.getRecordset( allUserMandates = appDb.getRecordset(
UserMandate, UserMandate,
recordFilter={"enabled": True} recordFilter={"enabled": True}
@ -711,68 +766,43 @@ class BillingObjects:
""" """
Check if there's sufficient balance for an operation. Check if there's sufficient balance for an operation.
Budget logic: - PREPAY_USER: user.balance >= estimatedCost
- PREPAY_USER: check user's own account balance - PREPAY_MANDATE: mandate pool balance >= estimatedCost
- PREPAY_MANDATE: check mandate pool balance (shared by all users)
- CREDIT_POSTPAY: check mandate pool credit limit
- UNLIMITED: always allowed
User accounts are always ensured to exist (for audit trail). User accounts are always ensured to exist (for audit trail).
Root mandate + PREPAY_USER: initial credit from settings.defaultUserCredit on first create.
Args: Missing settings: treated as PREPAY_MANDATE with empty pool (strict).
mandateId: Mandate ID
userId: User ID
estimatedCost: Estimated cost of the operation
Returns:
BillingCheckResult
""" """
settings = self.getSettings(mandateId) settings = self.getSettings(mandateId)
if not settings: if not settings:
return BillingCheckResult(allowed=True, billingModel=BillingModelEnum.UNLIMITED) billingModel = BillingModelEnum.PREPAY_MANDATE
defaultCredit = 0.0
else:
billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
defaultCredit = float(settings.get("defaultUserCredit", 0.0) or 0.0)
billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value)) rootMandateId = _getCachedRootMandateId()
isRootMandate = rootMandateId is not None and str(mandateId) == str(rootMandateId)
if billingModel == BillingModelEnum.UNLIMITED: if billingModel == BillingModelEnum.PREPAY_USER:
return BillingCheckResult(allowed=True, billingModel=billingModel) initialBalance = defaultCredit if isRootMandate else 0.0
else:
# Always ensure user account exists (for audit trail) initialBalance = 0.0
defaultCredit = settings.get("defaultUserCredit", 10.0)
initialBalance = defaultCredit if billingModel == BillingModelEnum.PREPAY_USER else 0.0
self.getOrCreateUserAccount(mandateId, userId, initialBalance=initialBalance) self.getOrCreateUserAccount(mandateId, userId, initialBalance=initialBalance)
# Determine which balance to check based on billing model
if billingModel == BillingModelEnum.PREPAY_USER: if billingModel == BillingModelEnum.PREPAY_USER:
account = self.getUserAccount(mandateId, userId) account = self.getUserAccount(mandateId, userId)
currentBalance = account.get("balance", 0.0) if account else 0.0 currentBalance = account.get("balance", 0.0) if account else 0.0
elif billingModel == BillingModelEnum.PREPAY_MANDATE:
poolAccount = self.getOrCreateMandateAccount(mandateId)
currentBalance = poolAccount.get("balance", 0.0)
elif billingModel == BillingModelEnum.CREDIT_POSTPAY:
poolAccount = self.getOrCreateMandateAccount(mandateId)
currentBalance = poolAccount.get("balance", 0.0)
creditLimit = poolAccount.get("creditLimit")
if creditLimit and abs(currentBalance) + estimatedCost > creditLimit:
return BillingCheckResult(
allowed=False,
reason="CREDIT_LIMIT_EXCEEDED",
currentBalance=currentBalance,
requiredAmount=estimatedCost,
billingModel=billingModel
)
return BillingCheckResult(allowed=True, currentBalance=currentBalance, billingModel=billingModel)
else: else:
return BillingCheckResult(allowed=True, billingModel=billingModel) poolAccount = self.getOrCreateMandateAccount(mandateId)
currentBalance = poolAccount.get("balance", 0.0)
# PREPAY models - check balance
if currentBalance < estimatedCost: if currentBalance < estimatedCost:
if settings.get("blockOnZeroBalance", True):
return BillingCheckResult( return BillingCheckResult(
allowed=False, allowed=False,
reason="INSUFFICIENT_BALANCE", reason="INSUFFICIENT_BALANCE",
currentBalance=currentBalance, currentBalance=currentBalance,
requiredAmount=estimatedCost, requiredAmount=estimatedCost,
billingModel=billingModel billingModel=billingModel,
) )
return BillingCheckResult(allowed=True, currentBalance=currentBalance, billingModel=billingModel) return BillingCheckResult(allowed=True, currentBalance=currentBalance, billingModel=billingModel)
@ -800,7 +830,6 @@ class BillingObjects:
Balance is deducted from the appropriate account based on billing model: Balance is deducted from the appropriate account based on billing model:
- PREPAY_USER: deduct from user's own balance - PREPAY_USER: deduct from user's own balance
- PREPAY_MANDATE: deduct from mandate pool balance - PREPAY_MANDATE: deduct from mandate pool balance
- CREDIT_POSTPAY: deduct from mandate pool balance
""" """
if priceCHF <= 0: if priceCHF <= 0:
return None return None
@ -810,10 +839,7 @@ class BillingObjects:
logger.debug(f"No billing settings for mandate {mandateId}, skipping usage recording") logger.debug(f"No billing settings for mandate {mandateId}, skipping usage recording")
return None return None
billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value)) billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
if billingModel == BillingModelEnum.UNLIMITED:
return None
# Transaction is ALWAYS on the user's account (audit trail) # Transaction is ALWAYS on the user's account (audit trail)
userAccount = self.getOrCreateUserAccount(mandateId, userId) userAccount = self.getOrCreateUserAccount(mandateId, userId)
@ -838,12 +864,11 @@ class BillingObjects:
# Determine where to deduct balance # Determine where to deduct balance
if billingModel == BillingModelEnum.PREPAY_USER: if billingModel == BillingModelEnum.PREPAY_USER:
# Deduct from user's own balance
return self.createTransaction(transaction) return self.createTransaction(transaction)
else: if billingModel == BillingModelEnum.PREPAY_MANDATE:
# PREPAY_MANDATE / CREDIT_POSTPAY: deduct from mandate pool
poolAccount = self.getOrCreateMandateAccount(mandateId) poolAccount = self.getOrCreateMandateAccount(mandateId)
return self.createTransaction(transaction, balanceAccountId=poolAccount["id"]) return self.createTransaction(transaction, balanceAccountId=poolAccount["id"])
return None
# ========================================================================= # =========================================================================
# Workflow Cost Query # Workflow Cost Query
@ -865,18 +890,10 @@ class BillingObjects:
def switchBillingModel(self, mandateId: str, oldModel: BillingModelEnum, newModel: BillingModelEnum) -> Dict[str, Any]: def switchBillingModel(self, mandateId: str, oldModel: BillingModelEnum, newModel: BillingModelEnum) -> Dict[str, Any]:
""" """
Switch billing model with automatic budget migration. Switch billing model with budget migration logged as BillingTransactions.
MANDATE -> USER: pool balance is distributed equally to all user accounts. PREPAY_MANDATE -> PREPAY_USER: pool debited, equal shares credited to user accounts.
USER -> MANDATE: all user balances are consolidated into the pool, user balances set to 0. PREPAY_USER -> PREPAY_MANDATE: user wallets debited, pool credited with sum.
Args:
mandateId: Mandate ID
oldModel: Current billing model
newModel: New billing model
Returns:
Migration result dict with details
""" """
result = {"oldModel": oldModel.value, "newModel": newModel.value, "migratedAmount": 0.0, "userCount": 0} result = {"oldModel": oldModel.value, "newModel": newModel.value, "migratedAmount": 0.0, "userCount": 0}
@ -884,47 +901,91 @@ class BillingObjects:
return result return result
if oldModel == BillingModelEnum.PREPAY_MANDATE and newModel == BillingModelEnum.PREPAY_USER: if oldModel == BillingModelEnum.PREPAY_MANDATE and newModel == BillingModelEnum.PREPAY_USER:
# Pool -> distribute equally to users
poolAccount = self.getMandateAccount(mandateId) poolAccount = self.getMandateAccount(mandateId)
if poolAccount and poolAccount.get("balance", 0.0) > 0:
poolBalance = poolAccount["balance"]
userAccounts = self.db.getRecordset( userAccounts = self.db.getRecordset(
BillingAccount, BillingAccount,
recordFilter={"mandateId": mandateId, "accountType": AccountTypeEnum.USER.value} recordFilter={"mandateId": mandateId, "accountType": AccountTypeEnum.USER.value}
) )
if userAccounts: poolBalance = poolAccount.get("balance", 0.0) if poolAccount else 0.0
perUser = poolBalance / len(userAccounts) n = len(userAccounts)
for acc in userAccounts: if poolAccount and poolBalance > 0:
newBalance = acc.get("balance", 0.0) + perUser self.createTransaction(
self.updateAccountBalance(acc["id"], newBalance) BillingTransaction(
self.updateAccountBalance(poolAccount["id"], 0.0) accountId=poolAccount["id"],
transactionType=TransactionTypeEnum.DEBIT,
amount=poolBalance,
description="Model switch: distributed from mandate pool to user wallets",
referenceType=ReferenceTypeEnum.SYSTEM,
)
)
result["migratedAmount"] = poolBalance result["migratedAmount"] = poolBalance
result["userCount"] = len(userAccounts) if n > 0:
remaining = poolBalance
for i, acc in enumerate(userAccounts):
if i == n - 1:
share = round(remaining, 4)
else:
share = round(poolBalance / n, 4)
remaining -= share
if share > 0:
self.createTransaction(
BillingTransaction(
accountId=acc["id"],
transactionType=TransactionTypeEnum.CREDIT,
amount=share,
description="Model switch: share from mandate pool",
referenceType=ReferenceTypeEnum.SYSTEM,
)
)
result["userCount"] = n
logger.info(
"Switched %s MANDATE->USER: migrated %.4f CHF to %d user account(s) (transactions logged)",
mandateId,
result["migratedAmount"],
result["userCount"],
)
return result
logger.info(f"Switched {mandateId} MANDATE->USER: distributed {result['migratedAmount']} CHF to {result['userCount']} users") if oldModel == BillingModelEnum.PREPAY_USER and newModel == BillingModelEnum.PREPAY_MANDATE:
elif oldModel == BillingModelEnum.PREPAY_USER and newModel == BillingModelEnum.PREPAY_MANDATE:
# Users -> consolidate into pool
userAccounts = self.db.getRecordset( userAccounts = self.db.getRecordset(
BillingAccount, BillingAccount,
recordFilter={"mandateId": mandateId, "accountType": AccountTypeEnum.USER.value} recordFilter={"mandateId": mandateId, "accountType": AccountTypeEnum.USER.value}
) )
totalUserBalance = sum(acc.get("balance", 0.0) for acc in userAccounts) totalUserBalance = sum(acc.get("balance", 0.0) for acc in userAccounts)
poolAccount = self.getOrCreateMandateAccount(mandateId, initialBalance=0.0)
newPoolBalance = poolAccount.get("balance", 0.0) + totalUserBalance
self.updateAccountBalance(poolAccount["id"], newPoolBalance)
for acc in userAccounts: for acc in userAccounts:
self.updateAccountBalance(acc["id"], 0.0) b = acc.get("balance", 0.0)
if b > 0:
self.createTransaction(
BillingTransaction(
accountId=acc["id"],
transactionType=TransactionTypeEnum.DEBIT,
amount=b,
description="Model switch: consolidated to mandate pool",
referenceType=ReferenceTypeEnum.SYSTEM,
)
)
poolAccount = self.getOrCreateMandateAccount(mandateId, initialBalance=0.0)
if totalUserBalance > 0:
self.createTransaction(
BillingTransaction(
accountId=poolAccount["id"],
transactionType=TransactionTypeEnum.CREDIT,
amount=totalUserBalance,
description="Model switch: consolidated from user accounts",
referenceType=ReferenceTypeEnum.SYSTEM,
)
)
result["migratedAmount"] = totalUserBalance result["migratedAmount"] = totalUserBalance
result["userCount"] = len(userAccounts) result["userCount"] = len(userAccounts)
logger.info(
"Switched %s USER->MANDATE: consolidated %.4f CHF from %d users into pool (transactions logged)",
mandateId,
totalUserBalance,
len(userAccounts),
)
return result
logger.info(f"Switched {mandateId} USER->MANDATE: consolidated {totalUserBalance} CHF from {len(userAccounts)} users into pool") if newModel == BillingModelEnum.PREPAY_MANDATE:
elif newModel == BillingModelEnum.PREPAY_MANDATE or newModel == BillingModelEnum.CREDIT_POSTPAY:
# Any -> MANDATE/CREDIT: ensure pool account exists
self.getOrCreateMandateAccount(mandateId, initialBalance=0.0) self.getOrCreateMandateAccount(mandateId, initialBalance=0.0)
return result return result
@ -1027,8 +1088,6 @@ class BillingObjects:
Shows the effective available budget: Shows the effective available budget:
- PREPAY_USER: user's own account balance - PREPAY_USER: user's own account balance
- PREPAY_MANDATE: mandate pool balance (shared budget visible to user) - PREPAY_MANDATE: mandate pool balance (shared budget visible to user)
- CREDIT_POSTPAY: mandate pool balance
Args: Args:
userId: User ID userId: User ID
@ -1060,25 +1119,20 @@ class BillingObjects:
if not settings: if not settings:
continue continue
billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value)) billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
if billingModel == BillingModelEnum.UNLIMITED:
continue
# Determine effective balance based on billing model
if billingModel == BillingModelEnum.PREPAY_USER: if billingModel == BillingModelEnum.PREPAY_USER:
account = self.getOrCreateUserAccount(mandateId, userId) account = self.getOrCreateUserAccount(mandateId, userId)
if not account: if not account:
continue continue
balance = account.get("balance", 0.0) balance = account.get("balance", 0.0)
warningThreshold = account.get("warningThreshold", 0.0) warningThreshold = account.get("warningThreshold", 0.0)
creditLimit = account.get("creditLimit") elif billingModel == BillingModelEnum.PREPAY_MANDATE:
elif billingModel in [BillingModelEnum.PREPAY_MANDATE, BillingModelEnum.CREDIT_POSTPAY]:
poolAccount = self.getOrCreateMandateAccount(mandateId) poolAccount = self.getOrCreateMandateAccount(mandateId)
if not poolAccount: if not poolAccount:
continue continue
balance = poolAccount.get("balance", 0.0) balance = poolAccount.get("balance", 0.0)
warningThreshold = poolAccount.get("warningThreshold", 0.0) warningThreshold = poolAccount.get("warningThreshold", 0.0)
creditLimit = poolAccount.get("creditLimit")
else: else:
continue continue
@ -1089,7 +1143,6 @@ class BillingObjects:
balance=balance, balance=balance,
warningThreshold=warningThreshold, warningThreshold=warningThreshold,
isWarning=balance <= warningThreshold, isWarning=balance <= warningThreshold,
creditLimit=creditLimit
)) ))
except Exception as e: except Exception as e:
logger.error(f"Error getting balances for user: {e}") logger.error(f"Error getting balances for user: {e}")
@ -1183,7 +1236,7 @@ class BillingObjects:
if not mandateId: if not mandateId:
continue continue
billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value)) billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
# Get mandate info # Get mandate info
mandate = appInterface.getMandate(mandateId) mandate = appInterface.getMandate(mandateId)
@ -1198,12 +1251,9 @@ class BillingObjects:
) )
userCount = len(userAccounts) userCount = len(userAccounts)
# Total balance depends on billing model
if billingModel == BillingModelEnum.PREPAY_USER: if billingModel == BillingModelEnum.PREPAY_USER:
# Budget is distributed across user accounts
totalBalance = sum(acc.get("balance", 0.0) for acc in userAccounts) totalBalance = sum(acc.get("balance", 0.0) for acc in userAccounts)
elif billingModel in [BillingModelEnum.PREPAY_MANDATE, BillingModelEnum.CREDIT_POSTPAY]: elif billingModel == BillingModelEnum.PREPAY_MANDATE:
# Budget is in the mandate pool
poolAccount = self.getMandateAccount(mandateId) poolAccount = self.getMandateAccount(mandateId)
totalBalance = poolAccount.get("balance", 0.0) if poolAccount else 0.0 totalBalance = poolAccount.get("balance", 0.0) if poolAccount else 0.0
else: else:
@ -1215,9 +1265,8 @@ class BillingObjects:
"billingModel": billingModel.value, "billingModel": billingModel.value,
"totalBalance": totalBalance, "totalBalance": totalBalance,
"userCount": userCount, "userCount": userCount,
"defaultUserCredit": settings.get("defaultUserCredit", 0.0), "defaultUserCredit": float(settings.get("defaultUserCredit", 0.0) or 0.0),
"warningThresholdPercent": settings.get("warningThresholdPercent", 10.0), "warningThresholdPercent": settings.get("warningThresholdPercent", 10.0),
"blockOnZeroBalance": settings.get("blockOnZeroBalance", True)
}) })
except Exception as e: except Exception as e:

View file

@ -17,7 +17,7 @@ Data Namespace Structure:
GROUP-Berechtigung: GROUP-Berechtigung:
- data.uam.*: GROUP filtert nach Mandant (via UserMandate) - data.uam.*: GROUP filtert nach Mandant (via UserMandate)
- data.chat.*, data.files.*, data.automation.*: GROUP = MY (benutzer-eigen, kein Mandantenkontext) - data.chat.*, data.files.*, data.automation.*: GROUP = MY (benutzer-eigen); bei gesetztem featureInstanceId zusätzlich _createdBy
- data.feature.*: GROUP filtert nach mandateId/featureInstanceId - data.feature.*: GROUP filtert nach mandateId/featureInstanceId
""" """
@ -344,6 +344,20 @@ def buildRbacWhereClause(
# All records within the feature instance - only featureInstanceId filtering # All records within the feature instance - only featureInstanceId filtering
if readLevel == AccessLevel.ALL: if readLevel == AccessLevel.ALL:
# Chat / AI Workspace: even DATA read ALL must not list other users' rows in a
# shared featureInstance (stale RBAC rules or merged roles). Same as MY.
namespaceAll = TABLE_NAMESPACE.get(table, "system")
if featureInstanceId and namespaceAll == "chat":
userIdFieldAll = "_createdBy"
if table == "UserInDB":
userIdFieldAll = "id"
elif table == "UserConnection":
userIdFieldAll = "userId"
conditionsAll = list(baseConditions)
valuesAll = list(baseValues)
conditionsAll.append(f'"{userIdFieldAll}" = %s')
valuesAll.append(currentUser.id)
return {"condition": " AND ".join(conditionsAll), "values": valuesAll}
if baseConditions: if baseConditions:
return {"condition": " AND ".join(baseConditions), "values": baseValues} return {"condition": " AND ".join(baseConditions), "values": baseValues}
return None return None
@ -379,6 +393,14 @@ def buildRbacWhereClause(
# But still apply featureInstanceId filter if provided # But still apply featureInstanceId filter if provided
if namespace in USER_OWNED_NAMESPACES: if namespace in USER_OWNED_NAMESPACES:
if baseConditions: if baseConditions:
# Shared feature instance: GROUP would otherwise only filter by featureInstanceId
# and expose every user's rows in that instance (e.g. ChatWorkflow).
if featureInstanceId and readLevel == AccessLevel.GROUP:
conditions = list(baseConditions)
values = list(baseValues)
conditions.append('"_createdBy" = %s')
values.append(currentUser.id)
return {"condition": " AND ".join(conditions), "values": values}
return {"condition": " AND ".join(baseConditions), "values": baseValues} return {"condition": " AND ".join(baseConditions), "values": baseValues}
return None return None

View file

@ -22,9 +22,18 @@ from modules.datamodels.datamodelFeatures import Feature, FeatureInstance
from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceDbApp import getRootInterface
from modules.interfaces.interfaceFeatures import getFeatureInterface from modules.interfaces.interfaceFeatures import getFeatureInterface
from modules.security.rbacCatalog import getCatalogService from modules.security.rbacCatalog import getCatalogService
from modules.routes.routeNotifications import create_access_change_notification
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _feature_instance_display_name(instance: Any) -> str:
if instance is None:
return ""
if isinstance(instance, dict):
return str(instance.get("label") or instance.get("uiLabel") or instance.get("id", ""))
return str(getattr(instance, "label", None) or getattr(instance, "uiLabel", None) or getattr(instance, "id", ""))
router = APIRouter( router = APIRouter(
prefix="/api/features", prefix="/api/features",
tags=["Features"], tags=["Features"],
@ -1025,6 +1034,15 @@ def add_user_to_feature_instance(
f"with roles {data.roleIds}" f"with roles {data.roleIds}"
) )
iname = _feature_instance_display_name(instance)
create_access_change_notification(
data.userId,
"Feature-Zugriff",
f"Sie haben Zugriff auf die Feature-Instanz «{iname}» erhalten.",
"feature_access",
instanceId,
)
return { return {
"featureAccessId": featureAccessId, "featureAccessId": featureAccessId,
"userId": data.userId, "userId": data.userId,
@ -1105,6 +1123,15 @@ def remove_user_from_feature_instance(
f"User {context.user.id} removed user {userId} from feature instance {instanceId}" f"User {context.user.id} removed user {userId} from feature instance {instanceId}"
) )
iname = _feature_instance_display_name(instance)
create_access_change_notification(
userId,
"Feature-Zugriff",
f"Ihr Zugriff auf die Feature-Instanz «{iname}» wurde entfernt.",
"feature_access",
instanceId,
)
return { return {
"message": "User access removed", "message": "User access removed",
"userId": userId, "userId": userId,
@ -1198,6 +1225,15 @@ def update_feature_instance_user_roles(
f"User {context.user.id} updated roles for user {userId} in feature instance {instanceId}: {data.roleIds}" f"User {context.user.id} updated roles for user {userId} in feature instance {instanceId}: {data.roleIds}"
) )
iname = _feature_instance_display_name(instance)
create_access_change_notification(
userId,
"Feature-Rollen geändert",
f"Ihre Rollen in der Feature-Instanz «{iname}» wurden angepasst.",
"feature_access",
instanceId,
)
return { return {
"featureAccessId": featureAccessId, "featureAccessId": featureAccessId,
"userId": userId, "userId": userId,

View file

@ -28,7 +28,6 @@ from modules.datamodels.datamodelBilling import (
BillingAccount, BillingAccount,
BillingTransaction, BillingTransaction,
BillingSettings, BillingSettings,
BillingAddress,
BillingModelEnum, BillingModelEnum,
TransactionTypeEnum, TransactionTypeEnum,
ReferenceTypeEnum, ReferenceTypeEnum,
@ -37,6 +36,7 @@ from modules.datamodels.datamodelBilling import (
BillingStatisticsResponse, BillingStatisticsResponse,
BillingStatisticsChartData, BillingStatisticsChartData,
BillingCheckResult, BillingCheckResult,
parseBillingModelFromStoredValue,
) )
# Configure logger # Configure logger
@ -263,10 +263,8 @@ class BillingSettingsUpdate(BaseModel):
billingModel: Optional[BillingModelEnum] = None billingModel: Optional[BillingModelEnum] = None
defaultUserCredit: Optional[float] = Field(None, ge=0) defaultUserCredit: Optional[float] = Field(None, ge=0)
warningThresholdPercent: Optional[float] = Field(None, ge=0, le=100) warningThresholdPercent: Optional[float] = Field(None, ge=0, le=100)
blockOnZeroBalance: Optional[bool] = None
notifyOnWarning: Optional[bool] = None notifyOnWarning: Optional[bool] = None
notifyEmails: Optional[List[str]] = None notifyEmails: Optional[List[str]] = None
billingAddress: Optional[BillingAddress] = None
class TransactionResponse(BaseModel): class TransactionResponse(BaseModel):
@ -295,7 +293,6 @@ class AccountSummary(BaseModel):
userId: Optional[str] userId: Optional[str]
accountType: str accountType: str
balance: float balance: float
creditLimit: Optional[float]
warningThreshold: float warningThreshold: float
enabled: bool enabled: bool
@ -323,7 +320,6 @@ class MandateBalanceResponse(BaseModel):
userCount: int userCount: int
defaultUserCredit: float defaultUserCredit: float
warningThresholdPercent: float warningThresholdPercent: float
blockOnZeroBalance: bool
class UserBalanceResponse(BaseModel): class UserBalanceResponse(BaseModel):
@ -427,12 +423,12 @@ def _creditStripeSessionIfNeeded(
if not settings: if not settings:
raise HTTPException(status_code=404, detail="Billing settings not found") raise HTTPException(status_code=404, detail="Billing settings not found")
billing_model = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value)) billing_model = parseBillingModelFromStoredValue(settings.get("billingModel"))
if billing_model == BillingModelEnum.PREPAY_USER: if billing_model == BillingModelEnum.PREPAY_USER:
if not user_id: if not user_id:
raise HTTPException(status_code=400, detail="userId required for PREPAY_USER") raise HTTPException(status_code=400, detail="userId required for PREPAY_USER")
account = billingInterface.getOrCreateUserAccount(mandate_id, user_id, initialBalance=0.0) account = billingInterface.getOrCreateUserAccount(mandate_id, user_id, initialBalance=0.0)
elif billing_model in [BillingModelEnum.PREPAY_MANDATE, BillingModelEnum.CREDIT_POSTPAY]: elif billing_model == BillingModelEnum.PREPAY_MANDATE:
account = billingInterface.getOrCreateMandateAccount(mandate_id, initialBalance=0.0) account = billingInterface.getOrCreateMandateAccount(mandate_id, initialBalance=0.0)
else: else:
raise HTTPException(status_code=400, detail=f"Cannot add credit to {billing_model.value}") raise HTTPException(status_code=400, detail=f"Cannot add credit to {billing_model.value}")
@ -529,11 +525,10 @@ def getBalanceForMandate(
return BillingBalanceResponse( return BillingBalanceResponse(
mandateId=targetMandateId, mandateId=targetMandateId,
mandateName=mandateName, mandateName=mandateName,
billingModel=checkResult.billingModel or BillingModelEnum.UNLIMITED, billingModel=checkResult.billingModel or BillingModelEnum.PREPAY_MANDATE,
balance=checkResult.currentBalance or 0.0, balance=checkResult.currentBalance or 0.0,
warningThreshold=0.0, # TODO: Get from account warningThreshold=0.0, # TODO: Get from account
isWarning=False, isWarning=False,
creditLimit=None
) )
except Exception as e: except Exception as e:
@ -622,7 +617,7 @@ def getStatistics(
costByFeature={} costByFeature={}
) )
billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value)) billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
# Transactions are always on user accounts (audit trail) # Transactions are always on user accounts (audit trail)
account = billingInterface.getUserAccount(ctx.mandateId, ctx.user.id) account = billingInterface.getUserAccount(ctx.mandateId, ctx.user.id)
@ -750,8 +745,12 @@ def createOrUpdateSettings(
if updates: if updates:
# Check if billing model is changing - trigger budget migration # Check if billing model is changing - trigger budget migration
if "billingModel" in updates: if "billingModel" in updates:
oldModel = BillingModelEnum(existingSettings.get("billingModel", BillingModelEnum.UNLIMITED.value)) oldModel = parseBillingModelFromStoredValue(existingSettings.get("billingModel"))
newModel = BillingModelEnum(updates["billingModel"]) if isinstance(updates["billingModel"], str) else updates["billingModel"] newModel = (
BillingModelEnum(updates["billingModel"])
if isinstance(updates["billingModel"], str)
else updates["billingModel"]
)
if oldModel != newModel: if oldModel != newModel:
migrationResult = billingInterface.switchBillingModel(targetMandateId, oldModel, newModel) migrationResult = billingInterface.switchBillingModel(targetMandateId, oldModel, newModel)
logger.info(f"Billing model migration for {targetMandateId}: {migrationResult}") logger.info(f"Billing model migration for {targetMandateId}: {migrationResult}")
@ -764,13 +763,27 @@ def createOrUpdateSettings(
newSettings = BillingSettings( newSettings = BillingSettings(
mandateId=targetMandateId, mandateId=targetMandateId,
billingModel=settingsUpdate.billingModel or BillingModelEnum.UNLIMITED, billingModel=(
defaultUserCredit=settingsUpdate.defaultUserCredit or 10.0, settingsUpdate.billingModel
warningThresholdPercent=settingsUpdate.warningThresholdPercent or 10.0, if settingsUpdate.billingModel is not None
blockOnZeroBalance=settingsUpdate.blockOnZeroBalance if settingsUpdate.blockOnZeroBalance is not None else True, else BillingModelEnum.PREPAY_MANDATE
notifyOnWarning=settingsUpdate.notifyOnWarning if settingsUpdate.notifyOnWarning is not None else True, ),
defaultUserCredit=(
settingsUpdate.defaultUserCredit
if settingsUpdate.defaultUserCredit is not None
else 0.0
),
warningThresholdPercent=(
settingsUpdate.warningThresholdPercent
if settingsUpdate.warningThresholdPercent is not None
else 10.0
),
notifyOnWarning=(
settingsUpdate.notifyOnWarning
if settingsUpdate.notifyOnWarning is not None
else True
),
notifyEmails=settingsUpdate.notifyEmails or [], notifyEmails=settingsUpdate.notifyEmails or [],
billingAddress=settingsUpdate.billingAddress
) )
return billingInterface.createSettings(newSettings) return billingInterface.createSettings(newSettings)
@ -803,7 +816,7 @@ def addCredit(
if not settings: if not settings:
raise HTTPException(status_code=404, detail="Billing settings not found for this mandate") raise HTTPException(status_code=404, detail="Billing settings not found for this mandate")
billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value)) billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
# Validate request based on billing model # Validate request based on billing model
if billingModel == BillingModelEnum.PREPAY_USER: if billingModel == BillingModelEnum.PREPAY_USER:
@ -816,7 +829,7 @@ def addCredit(
creditRequest.userId, creditRequest.userId,
initialBalance=0.0 initialBalance=0.0
) )
elif billingModel in [BillingModelEnum.PREPAY_MANDATE, BillingModelEnum.CREDIT_POSTPAY]: elif billingModel == BillingModelEnum.PREPAY_MANDATE:
# Create mandate-level account if needed and add credit # Create mandate-level account if needed and add credit
account = billingInterface.getOrCreateMandateAccount(targetMandateId, initialBalance=0.0) account = billingInterface.getOrCreateMandateAccount(targetMandateId, initialBalance=0.0)
else: else:
@ -866,7 +879,7 @@ def createCheckoutSession(
if not settings: if not settings:
raise HTTPException(status_code=404, detail="Billing settings not found for this mandate") raise HTTPException(status_code=404, detail="Billing settings not found for this mandate")
billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value)) billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
if billingModel == BillingModelEnum.PREPAY_USER: if billingModel == BillingModelEnum.PREPAY_USER:
if not checkoutRequest.userId: if not checkoutRequest.userId:
@ -875,7 +888,7 @@ def createCheckoutSession(
raise HTTPException(status_code=403, detail="Users can only load credit to their own account") raise HTTPException(status_code=403, detail="Users can only load credit to their own account")
if not _isMemberOfMandate(ctx, targetMandateId): if not _isMemberOfMandate(ctx, targetMandateId):
raise HTTPException(status_code=403, detail="User is not a member of this mandate") raise HTTPException(status_code=403, detail="User is not a member of this mandate")
elif billingModel in [BillingModelEnum.PREPAY_MANDATE, BillingModelEnum.CREDIT_POSTPAY]: elif billingModel == BillingModelEnum.PREPAY_MANDATE:
if not _isAdminOfMandate(ctx, targetMandateId): if not _isAdminOfMandate(ctx, targetMandateId):
raise HTTPException(status_code=403, detail="Mandate admin role required to load mandate credit") raise HTTPException(status_code=403, detail="Mandate admin role required to load mandate credit")
else: else:
@ -933,7 +946,7 @@ def confirmCheckoutSession(
if not settings: if not settings:
raise HTTPException(status_code=404, detail="Billing settings not found") raise HTTPException(status_code=404, detail="Billing settings not found")
billing_model = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value)) billing_model = parseBillingModelFromStoredValue(settings.get("billingModel"))
if billing_model == BillingModelEnum.PREPAY_USER: if billing_model == BillingModelEnum.PREPAY_USER:
if not user_id: if not user_id:
raise HTTPException(status_code=400, detail="userId required for PREPAY_USER") raise HTTPException(status_code=400, detail="userId required for PREPAY_USER")
@ -941,7 +954,7 @@ def confirmCheckoutSession(
raise HTTPException(status_code=403, detail="Users can only confirm their own payment sessions") raise HTTPException(status_code=403, detail="Users can only confirm their own payment sessions")
if not _isMemberOfMandate(ctx, mandate_id): if not _isMemberOfMandate(ctx, mandate_id):
raise HTTPException(status_code=403, detail="User is not a member of this mandate") raise HTTPException(status_code=403, detail="User is not a member of this mandate")
elif billing_model in [BillingModelEnum.PREPAY_MANDATE, BillingModelEnum.CREDIT_POSTPAY]: elif billing_model == BillingModelEnum.PREPAY_MANDATE:
if not _isAdminOfMandate(ctx, mandate_id): if not _isAdminOfMandate(ctx, mandate_id):
raise HTTPException(status_code=403, detail="Mandate admin role required") raise HTTPException(status_code=403, detail="Mandate admin role required")
else: else:
@ -1041,7 +1054,6 @@ def getAccounts(
userId=acc.get("userId"), userId=acc.get("userId"),
accountType=acc.get("accountType"), accountType=acc.get("accountType"),
balance=acc.get("balance", 0.0), balance=acc.get("balance", 0.0),
creditLimit=acc.get("creditLimit"),
warningThreshold=acc.get("warningThreshold", 0.0), warningThreshold=acc.get("warningThreshold", 0.0),
enabled=acc.get("enabled", True) enabled=acc.get("enabled", True)
)) ))

View file

@ -16,6 +16,7 @@ from fastapi import status
import logging import logging
import json import json
import math import math
from urllib.parse import quote
from modules.datamodels.datamodelUam import User, UserConnection, AuthAuthority, ConnectionStatus from modules.datamodels.datamodelUam import User, UserConnection, AuthAuthority, ConnectionStatus
from modules.datamodels.datamodelSecurity import Token from modules.datamodels.datamodelSecurity import Token
@ -445,24 +446,12 @@ def connect_service(
detail="Connection not found" detail="Connection not found"
) )
# Initiate OAuth flow with state=connect # Data-app OAuth (JWT state issued server-side in /auth/connect)
auth_url = None auth_url = None
if connection.authority == AuthAuthority.MSFT: if connection.authority == AuthAuthority.MSFT:
# Use the same login endpoint with state=connect to ensure account selection auth_url = f"/api/msft/auth/connect?connectionId={quote(connectionId, safe='')}"
# Include current user ID in state
state_data = {
"type": "connect",
"connectionId": connectionId,
"userId": currentUser.id # Add current user ID
}
auth_url = f"/api/msft/login?state={json.dumps(state_data)}"
elif connection.authority == AuthAuthority.GOOGLE: elif connection.authority == AuthAuthority.GOOGLE:
state_data = { auth_url = f"/api/google/auth/connect?connectionId={quote(connectionId, safe='')}"
"type": "connect",
"connectionId": connectionId,
"userId": currentUser.id # Add current user ID
}
auth_url = f"/api/google/login?state={json.dumps(state_data)}"
else: else:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,

View file

@ -21,6 +21,7 @@ from modules.auth import limiter, requireSysAdminRole, getRequestContext, Reques
# Import interfaces # Import interfaces
import modules.interfaces.interfaceDbApp as interfaceDbApp import modules.interfaces.interfaceDbApp as interfaceDbApp
from modules.interfaces.interfaceDbBilling import _getRootInterface as _getBillingRootInterface
from modules.shared.attributeUtils import getModelAttributeDefinitions from modules.shared.attributeUtils import getModelAttributeDefinitions
from modules.shared.auditLogger import audit_logger from modules.shared.auditLogger import audit_logger
@ -29,6 +30,7 @@ from modules.datamodels.datamodelUam import Mandate, User
from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
from modules.datamodels.datamodelRbac import Role from modules.datamodels.datamodelRbac import Role
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
from modules.routes.routeNotifications import create_access_change_notification
# ============================================================================= # =============================================================================
@ -248,6 +250,15 @@ def create_mandate(
detail="Failed to create mandate" detail="Failed to create mandate"
) )
try:
billingInterface = _getBillingRootInterface()
billingInterface.getOrCreateSettings(str(newMandate.id))
logger.debug(f"Ensured billing settings for new mandate {newMandate.id}")
except Exception as billingErr:
logger.warning(
f"Could not create default billing settings for mandate {newMandate.id}: {billingErr}"
)
logger.info(f"Mandate {newMandate.id} created by SysAdmin {currentUser.id}") logger.info(f"Mandate {newMandate.id} created by SysAdmin {currentUser.id}")
return newMandate return newMandate
@ -613,6 +624,15 @@ def add_user_to_mandate(
f"with roles {data.roleIds}" f"with roles {data.roleIds}"
) )
mname = _mandate_display_name(mandate)
create_access_change_notification(
data.targetUserId,
"Mandantenzugriff",
f"Sie wurden dem Mandanten «{mname}» hinzugefügt.",
"mandate_access",
targetMandateId,
)
return UserMandateResponse( return UserMandateResponse(
id=str(userMandate.id), # UserMandate ID as primary key id=str(userMandate.id), # UserMandate ID as primary key
userId=data.targetUserId, userId=data.targetUserId,
@ -697,6 +717,15 @@ def remove_user_from_mandate(
logger.info(f"User {context.user.id} removed user {targetUserId} from mandate {targetMandateId}") logger.info(f"User {context.user.id} removed user {targetUserId} from mandate {targetMandateId}")
mname = _mandate_display_name(mandate)
create_access_change_notification(
targetUserId,
"Mandantenzugriff",
f"Sie wurden aus dem Mandanten «{mname}» entfernt.",
"mandate_access",
targetMandateId,
)
return {"message": "User removed from mandate", "userId": targetUserId} return {"message": "User removed from mandate", "userId": targetUserId}
except HTTPException: except HTTPException:
@ -792,6 +821,16 @@ def update_user_roles_in_mandate(
f"in mandate {targetMandateId} to {roleIds}" f"in mandate {targetMandateId} to {roleIds}"
) )
mandate_meta = rootInterface.getMandate(targetMandateId)
mname = _mandate_display_name(mandate_meta)
create_access_change_notification(
targetUserId,
"Mandantenrollen geändert",
f"Ihre Rollen im Mandanten «{mname}» wurden angepasst.",
"mandate_access",
targetMandateId,
)
return UserMandateResponse( return UserMandateResponse(
id=str(membership.id), # UserMandate ID as primary key id=str(membership.id), # UserMandate ID as primary key
userId=targetUserId, userId=targetUserId,
@ -814,6 +853,28 @@ def update_user_roles_in_mandate(
# Helper Functions # Helper Functions
# ============================================================================= # =============================================================================
def _mandate_display_name(mandate: Any) -> str:
"""Human-readable mandate label for notifications."""
if mandate is None:
return ""
if isinstance(mandate, dict):
if mandate.get("label"):
return str(mandate["label"])
name = mandate.get("name")
if isinstance(name, dict):
return str(name.get("de") or name.get("en") or (next(iter(name.values()), "") if name else ""))
return str(name or mandate.get("id", ""))
label = getattr(mandate, "label", None)
if label:
return str(label)
name = getattr(mandate, "name", None)
if isinstance(name, dict):
return str(name.get("de") or name.get("en") or (next(iter(name.values()), "") if name else ""))
if name is not None:
return str(name)
return str(getattr(mandate, "id", ""))
def _getAdminMandateIds(context: RequestContext) -> List[str]: def _getAdminMandateIds(context: RequestContext) -> List[str]:
""" """
Get list of mandate IDs where the user has the admin role. Get list of mandate IDs where the user has the admin role.

View file

@ -89,6 +89,31 @@ def _createNotification(
return notification return notification
def create_access_change_notification(
userId: str,
title: str,
message: str,
reference_type: str,
reference_id: Optional[str] = None,
) -> None:
"""
In-app notification for mandate/feature access changes (triggers client nav refresh).
Failures are logged only so RBAC mutations still succeed.
"""
try:
_createNotification(
userId=userId,
notificationType=NotificationType.SYSTEM,
title=title,
message=message,
referenceType=reference_type,
referenceId=reference_id,
icon="shield",
)
except Exception as e:
logger.warning(f"Could not create access-change notification for user {userId}: {e}")
def createInvitationNotification( def createInvitationNotification(
userId: str, userId: str,
invitationId: str, invitationId: str,

File diff suppressed because it is too large Load diff

View file

@ -18,7 +18,7 @@ from modules.auth import getCurrentUser, limiter, SECRET_KEY, ALGORITHM
from modules.auth import createAccessToken, createRefreshToken, setAccessTokenCookie, setRefreshTokenCookie, clearAccessTokenCookie, clearRefreshTokenCookie from modules.auth import createAccessToken, createRefreshToken, setAccessTokenCookie, setRefreshTokenCookie, clearAccessTokenCookie, clearRefreshTokenCookie
from modules.interfaces.interfaceDbApp import getInterface, getRootInterface from modules.interfaces.interfaceDbApp import getInterface, getRootInterface
from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority, Mandate from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority, Mandate
from modules.datamodels.datamodelSecurity import Token from modules.datamodels.datamodelSecurity import Token, TokenPurpose
from modules.shared.configuration import APP_CONFIG from modules.shared.configuration import APP_CONFIG
from modules.shared.timeUtils import getUtcTimestamp from modules.shared.timeUtils import getUtcTimestamp
@ -164,6 +164,7 @@ def login(
id=jti, id=jti,
userId=user.id, userId=user.id,
authority=AuthAuthority.LOCAL, authority=AuthAuthority.LOCAL,
tokenPurpose=TokenPurpose.AUTH_SESSION,
tokenAccess=access_token, tokenAccess=access_token,
tokenType="bearer", tokenType="bearer",
expiresAt=expires_at.timestamp(), expiresAt=expires_at.timestamp(),

File diff suppressed because it is too large Load diff

View file

@ -110,15 +110,38 @@ def _getUserFeatureAccess(db, userId: str, instanceId: str) -> Dict[str, Any] |
return accesses[0] if accesses else None return accesses[0] if accesses else None
def _findUserRole(rootInterface, instanceId: str, featureCode: str) -> str | None: def _findStoreUserRoleId(
"""Find the user-level role for a feature instance.""" rootInterface,
catalogService,
instanceId: str,
featureCode: str,
) -> str | None:
"""
Resolve the feature's primary *user* role on this instance (e.g. workspace-user).
Uses catalog template labels first, then a safe fallback on instance roles.
"""
instanceRoles = rootInterface.getRolesByFeatureInstance(instanceId) instanceRoles = rootInterface.getRolesByFeatureInstance(instanceId)
userRoleLabel = f"{featureCode}-user" labelToId = {r.roleLabel: str(r.id) for r in instanceRoles if r.roleLabel}
preferred = f"{featureCode}-user"
if preferred in labelToId:
return labelToId[preferred]
for tpl in catalogService.getTemplateRoles(featureCode):
lbl = (tpl.get("roleLabel") or "").strip()
if not lbl:
continue
low = lbl.lower()
if "admin" in low:
continue
if lbl.endswith("-user") and lbl in labelToId:
return labelToId[lbl]
for role in instanceRoles: for role in instanceRoles:
if role.roleLabel == userRoleLabel: low = (role.roleLabel or "").lower()
return str(role.id) if "admin" in low:
for role in instanceRoles: continue
if "user" in role.roleLabel.lower() and "admin" not in role.roleLabel.lower(): if "user" in low:
return str(role.id) return str(role.id)
return None return None
@ -249,8 +272,20 @@ def activateStoreFeature(
createdAccess = db.recordCreate(FeatureAccess, featureAccess.model_dump()) createdAccess = db.recordCreate(FeatureAccess, featureAccess.model_dump())
featureAccessId = createdAccess.get("id") featureAccessId = createdAccess.get("id")
userRoleId = _findUserRole(rootInterface, instanceId, featureCode) userRoleId = _findStoreUserRoleId(rootInterface, catalogService, instanceId, featureCode)
if userRoleId: if not userRoleId:
db.recordDelete(FeatureAccess, featureAccessId)
logger.error(
f"Store activate rollback: no user role on instance {instanceId} for feature '{featureCode}'"
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=(
f"No '{featureCode}-user' (or equivalent) role found on the shared instance; "
"cannot grant store access. Contact an administrator."
),
)
featureAccessRole = FeatureAccessRole( featureAccessRole = FeatureAccessRole(
featureAccessId=featureAccessId, featureAccessId=featureAccessId,
roleId=userRoleId roleId=userRoleId

View file

@ -8,7 +8,7 @@ Core service - not requested by features directly.
import logging import logging
from typing import Optional, Callable, Any from typing import Optional, Callable, Any
from modules.datamodels.datamodelSecurity import Token from modules.datamodels.datamodelSecurity import Token, TokenPurpose
from modules.auth import TokenManager from modules.auth import TokenManager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -34,6 +34,16 @@ class SecurityService:
token = self._interfaceDbApp.getConnectionToken(connectionId) token = self._interfaceDbApp.getConnectionToken(connectionId)
if not token: if not token:
return None return None
_tp = (
token.tokenPurpose.value
if isinstance(token.tokenPurpose, TokenPurpose)
else token.tokenPurpose
)
if _tp != TokenPurpose.DATA_CONNECTION.value:
logger.warning(
f"getFreshToken: connection {connectionId} tokenPurpose is {_tp}, expected dataConnection"
)
return None
return self._tokenManager.ensureFreshToken( return self._tokenManager.ensureFreshToken(
token, token,
secondsBeforeExpiry=secondsBeforeExpiry, secondsBeforeExpiry=secondsBeforeExpiry,

View file

@ -22,6 +22,9 @@ from modules.serviceCenter.services.serviceAgent.conversationManager import (
) )
from modules.shared.timeUtils import getUtcTimestamp from modules.shared.timeUtils import getUtcTimestamp
from modules.shared.jsonUtils import closeJsonStructures from modules.shared.jsonUtils import closeJsonStructures
from modules.serviceCenter.services.serviceBilling.mainServiceBilling import (
InsufficientBalanceException,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -175,6 +178,18 @@ async def runAgentLoop(
else: else:
aiResponse = await aiCallFn(aiRequest) aiResponse = await aiCallFn(aiRequest)
except InsufficientBalanceException as e:
logger.warning(
f"Insufficient balance in round {state.currentRound} (mandate={mandateId}): {e.message}"
)
state.status = AgentStatusEnum.ERROR
state.abortReason = e.message
yield AgentEvent(
type=AgentEventTypeEnum.ERROR,
content=e.message,
data=e.toClientDict(),
)
break
except Exception as e: except Exception as e:
logger.error(f"AI call failed in round {state.currentRound}: {e}", exc_info=True) logger.error(f"AI call failed in round {state.currentRound}: {e}", exc_info=True)
state.status = AgentStatusEnum.ERROR state.status = AgentStatusEnum.ERROR

View file

@ -17,6 +17,10 @@ from modules.shared.jsonUtils import (
) )
from .subJsonResponseHandling import JsonResponseHandler from .subJsonResponseHandling import JsonResponseHandler
from modules.datamodels.datamodelAi import JsonAccumulationState from modules.datamodels.datamodelAi import JsonAccumulationState
from modules.datamodels.datamodelBilling import BillingModelEnum
from modules.serviceCenter.services.serviceBilling.billingExhaustedNotify import (
maybeEmailMandatePoolExhausted,
)
from modules.serviceCenter.services.serviceBilling.mainServiceBilling import ( from modules.serviceCenter.services.serviceBilling.mainServiceBilling import (
getService as getBillingService, getService as getBillingService,
InsufficientBalanceException, InsufficientBalanceException,
@ -592,10 +596,19 @@ detectedIntent-Werte:
f"Balance {balance_str} CHF, " f"Balance {balance_str} CHF, "
f"Reason: {balanceCheck.reason}" f"Reason: {balanceCheck.reason}"
) )
raise InsufficientBalanceException( if balanceCheck.billingModel == BillingModelEnum.PREPAY_MANDATE:
currentBalance=balanceCheck.currentBalance or 0.0, ulabel = (getattr(user, "email", None) or getattr(user, "username", None) or str(user.id))
requiredAmount=estimatedCost, maybeEmailMandatePoolExhausted(
message=f"Ungenugendes Guthaben. Aktuell: CHF {balance_str}" str(mandateId),
str(user.id),
str(ulabel),
float(balanceCheck.currentBalance or 0.0),
float(estimatedCost),
)
raise InsufficientBalanceException.fromBalanceCheck(
balanceCheck,
str(mandateId),
float(estimatedCost),
) )
balance_str = f"{(balanceCheck.currentBalance or 0):.2f}" balance_str = f"{(balanceCheck.currentBalance or 0):.2f}"

View file

@ -0,0 +1,138 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
When the shared mandate pool (PREPAY_MANDATE) is exhausted, notify billing contacts.
Recipients: BillingSettings.notifyEmails for the mandate (configure as mandate owner / finance).
Emails are throttled per mandate to avoid spam (one notification per cooldown window).
"""
from __future__ import annotations
import html
import logging
import time
from typing import Any, Dict, List, Optional
from modules.datamodels.datamodelMessaging import MessagingChannel
from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface
from modules.interfaces.interfaceMessaging import getInterface as getMessagingInterface
from modules.security.rootAccess import getRootUser
logger = logging.getLogger(__name__)
# mandate_id -> unix timestamp of last pool-exhausted notification email
_poolExhaustedEmailLastSent: Dict[str, float] = {}
_DEFAULT_COOLDOWN_SEC = 3600
def _normalizeNotifyEmails(raw: Any) -> List[str]:
if raw is None:
return []
if isinstance(raw, list):
return [str(e).strip() for e in raw if str(e).strip()]
if isinstance(raw, str):
s = raw.strip()
if not s:
return []
# JSON array string
if s.startswith("["):
try:
import json
parsed = json.loads(s)
if isinstance(parsed, list):
return [str(e).strip() for e in parsed if str(e).strip()]
except Exception:
pass
return [s]
return []
def maybeEmailMandatePoolExhausted(
mandateId: str,
triggeringUserId: str,
triggeringUserLabel: str,
currentBalance: float,
requiredAmount: float,
cooldownSec: float = _DEFAULT_COOLDOWN_SEC,
) -> None:
"""
Send one email per mandate per cooldown to BillingSettings.notifyEmails.
Args:
mandateId: Mandate whose pool is empty.
triggeringUserId: User who hit the block.
triggeringUserLabel: Display (e.g. email or username).
currentBalance: Pool balance (CHF).
requiredAmount: Minimum required (CHF).
cooldownSec: Minimum seconds between emails for this mandate.
"""
if not mandateId:
return
now = time.time()
last = _poolExhaustedEmailLastSent.get(mandateId, 0.0)
if last and (now - last) < cooldownSec:
logger.debug(
"Skip mandate pool exhausted email (cooldown): mandate=%s last=%.0fs ago",
mandateId,
now - last,
)
return
try:
billing = getBillingInterface(getRootUser(), mandateId)
settings = billing.getSettings(mandateId) or {}
recipients = _normalizeNotifyEmails(settings.get("notifyEmails"))
if not recipients:
logger.warning(
"PREPAY_MANDATE pool exhausted for mandate %s but notifyEmails is empty — "
"configure BillingSettings.notifyEmails for owner alerts",
mandateId,
)
return
subject = f"[PowerOn] Mandanten-Budget aufgebraucht (Mandant {mandateId[:8]}…)"
body = (
f"Das gemeinsame Guthaben (PREPAY_MANDATE) für diesen Mandanten ist nicht mehr ausreichend.\n\n"
f"Mandanten-ID: {mandateId}\n"
f"Aktuelles Guthaben (Pool): CHF {currentBalance:.2f}\n"
f"Benötigt (mind.): CHF {requiredAmount:.2f}\n\n"
f"Auslösende/r Benutzer/in: {triggeringUserLabel} (ID: {triggeringUserId})\n\n"
f"Bitte laden Sie das Mandats-Guthaben in der Billing-Verwaltung auf, "
f"damit Benutzer wieder AI-Funktionen nutzen können.\n"
)
escaped = html.escape(body)
htmlMessage = f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"></head>
<body style="font-family: Arial, sans-serif; line-height: 1.6;">
{escaped.replace(chr(10), '<br>\n')}
</body></html>"""
messaging = getMessagingInterface()
any_ok = False
for to in recipients:
try:
ok = messaging.send(
channel=MessagingChannel.EMAIL,
recipient=to,
subject=subject,
message=htmlMessage,
)
if ok:
any_ok = True
else:
logger.warning("Pool exhausted email failed for %s", to)
except Exception as send_err:
logger.error("Error sending pool exhausted email to %s: %s", to, send_err)
if any_ok:
_poolExhaustedEmailLastSent[mandateId] = now
logger.info(
"Sent mandate pool exhausted notification for mandate %s to %s recipient(s)",
mandateId,
len(recipients),
)
except Exception as e:
logger.error("maybeEmailMandatePoolExhausted failed: %s", e, exc_info=True)

View file

@ -22,6 +22,7 @@ from modules.datamodels.datamodelBilling import (
ReferenceTypeEnum, ReferenceTypeEnum,
BillingTransaction, BillingTransaction,
BillingBalanceResponse, BillingBalanceResponse,
parseBillingModelFromStoredValue,
) )
from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface
@ -333,7 +334,7 @@ class BillingService:
logger.warning(f"No billing settings for mandate {self.mandateId}") logger.warning(f"No billing settings for mandate {self.mandateId}")
return None return None
billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value)) billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
# Get or create account # Get or create account
if billingModel == BillingModelEnum.PREPAY_USER: if billingModel == BillingModelEnum.PREPAY_USER:
@ -389,15 +390,127 @@ class BillingService:
# Exception Classes # Exception Classes
# ============================================================================ # ============================================================================
class InsufficientBalanceException(Exception): BILLING_USER_ACTION_TOP_UP_SELF = "TOP_UP_SELF"
"""Raised when there's insufficient balance for an operation.""" BILLING_USER_ACTION_CONTACT_MANDATE_ADMIN = "CONTACT_MANDATE_ADMIN"
def __init__(self, currentBalance: float, requiredAmount: float, message: str = None):
self.currentBalance = currentBalance def _userActionForBillingModel(bm: BillingModelEnum) -> str:
self.requiredAmount = requiredAmount if bm == BillingModelEnum.PREPAY_USER:
self.message = message or f"Insufficient balance. Current: {currentBalance:.2f} CHF, Required: {requiredAmount:.2f} CHF" return BILLING_USER_ACTION_TOP_UP_SELF
return BILLING_USER_ACTION_CONTACT_MANDATE_ADMIN
def _buildInsufficientBalanceMessages(
bm: BillingModelEnum,
currentBalance: float,
requiredAmount: float,
) -> tuple:
bal_s = f"{currentBalance:.2f}"
req_s = f"{requiredAmount:.2f}"
if bm == BillingModelEnum.PREPAY_USER:
msg_de = (
f"Ihr persönliches Guthaben ist aufgebraucht (aktuell CHF {bal_s}, benötigt mindestens CHF {req_s}). "
"Bitte laden Sie unter „Billing“ Guthaben nach."
)
msg_en = (
f"Your personal balance is exhausted (current CHF {bal_s}, at least CHF {req_s} required). "
"Please top up under Billing."
)
else:
msg_de = (
f"Das Mandanten-Budget ist aufgebraucht (aktuell CHF {bal_s}, benötigt mindestens CHF {req_s}). "
"Bitte informieren Sie die Administratorin bzw. den Administrator Ihres Mandanten. "
"Die in den Billing-Einstellungen hinterlegten Kontakte wurden per E-Mail informiert (falls konfiguriert)."
)
msg_en = (
f"The organization budget is exhausted (current CHF {bal_s}, at least CHF {req_s} required). "
"Please contact your mandate administrator. Billing notification contacts were emailed if configured."
)
return msg_de, msg_en
class InsufficientBalanceException(Exception):
"""Raised when there's insufficient balance for an operation.
Carries structured fields for API/SSE clients (userAction, billingModel, localized hints).
"""
def __init__(
self,
currentBalance: float,
requiredAmount: float,
message: Optional[str] = None,
*,
billing_model: Optional[BillingModelEnum] = None,
mandate_id: str = "",
user_action: Optional[str] = None,
message_de: Optional[str] = None,
message_en: Optional[str] = None,
):
self.currentBalance = float(currentBalance)
self.requiredAmount = float(requiredAmount)
self.billing_model = billing_model
self.mandate_id = mandate_id or ""
if billing_model is not None:
self.user_action = user_action or _userActionForBillingModel(billing_model)
else:
self.user_action = user_action or BILLING_USER_ACTION_TOP_UP_SELF
if message_de is not None and message_en is not None:
self.message_de = message_de
self.message_en = message_en
self.message = message or message_de
elif message:
self.message = message
self.message_de = message
self.message_en = message
else:
bm = billing_model or BillingModelEnum.PREPAY_USER
md, me = _buildInsufficientBalanceMessages(bm, self.currentBalance, self.requiredAmount)
self.message_de = md
self.message_en = me
self.message = md
super().__init__(self.message) super().__init__(self.message)
@classmethod
def fromBalanceCheck(
cls,
check: BillingCheckResult,
mandate_id: str,
required_amount: float,
) -> "InsufficientBalanceException":
bm = check.billingModel or BillingModelEnum.PREPAY_MANDATE
bal = float(check.currentBalance or 0.0)
msg_de, msg_en = _buildInsufficientBalanceMessages(bm, bal, required_amount)
return cls(
bal,
required_amount,
message=msg_de,
billing_model=bm,
mandate_id=mandate_id or "",
message_de=msg_de,
message_en=msg_en,
)
def toClientDict(self) -> Dict[str, Any]:
"""Structured payload for HTTP 402, SSE item, or JSON error details."""
out: Dict[str, Any] = {
"error": "INSUFFICIENT_BALANCE",
"currentBalance": round(self.currentBalance, 4),
"requiredAmount": round(self.requiredAmount, 4),
"message": self.message,
"messageDe": self.message_de,
"messageEn": self.message_en,
"userAction": self.user_action,
}
if self.billing_model is not None:
out["billingModel"] = self.billing_model.value
if self.mandate_id:
out["mandateId"] = self.mandate_id
if self.user_action == BILLING_USER_ACTION_TOP_UP_SELF:
out["billingUiPath"] = "/billing"
return out
class ProviderNotAllowedException(Exception): class ProviderNotAllowedException(Exception):
"""Raised when a user doesn't have permission to use an AI provider.""" """Raised when a user doesn't have permission to use an AI provider."""

View file

@ -447,6 +447,11 @@ RESOURCE_OBJECTS = [
"label": {"en": "Store: AI Workspace", "de": "Store: AI Workspace", "fr": "Store: AI Workspace"}, "label": {"en": "Store: AI Workspace", "de": "Store: AI Workspace", "fr": "Store: AI Workspace"},
"meta": {"category": "store", "featureCode": "workspace"} "meta": {"category": "store", "featureCode": "workspace"}
}, },
{
"objectKey": "resource.store.commcoach",
"label": {"en": "Store: CommCoach", "de": "Store: CommCoach", "fr": "Store: CommCoach"},
"meta": {"category": "store", "featureCode": "commcoach"}
},
{ {
"objectKey": "resource.system.api.auth", "objectKey": "resource.system.api.auth",
"label": {"en": "Authentication API", "de": "Authentifizierungs-API", "fr": "API d'authentification"}, "label": {"en": "Authentication API", "de": "Authentifizierungs-API", "fr": "API d'authentification"},