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:
parent
eefb37050e
commit
0a0973d41b
32 changed files with 2179 additions and 1539 deletions
16
app.py
16
app.py
|
|
@ -8,7 +8,8 @@ from urllib.parse import quote_plus
|
|||
|
||||
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.security import HTTPBearer
|
||||
from contextlib import asynccontextmanager
|
||||
|
|
@ -493,6 +494,19 @@ from slowapi import _rate_limit_exceeded_handler
|
|||
app.state.limiter = limiter
|
||||
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
|
||||
from modules.auth import CSRFMiddleware
|
||||
from modules.auth import (
|
||||
|
|
|
|||
24
env_dev.env
24
env_dev.env
|
|
@ -31,9 +31,20 @@ APP_LOGGING_FILE_ENABLED = True
|
|||
APP_LOGGING_ROTATION_SIZE = 10485760
|
||||
APP_LOGGING_BACKUP_COUNT = 5
|
||||
|
||||
# Service Redirects
|
||||
Service_MSFT_REDIRECT_URI = http://localhost:8000/api/msft/auth/callback
|
||||
Service_GOOGLE_REDIRECT_URI = http://localhost:8000/api/google/auth/callback
|
||||
# OAuth: Auth app (login/JWT) vs Data app (Microsoft Graph / Google APIs). Same IDs until you split apps in Azure / GCP.
|
||||
Service_MSFT_AUTH_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
|
||||
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_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_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
|
||||
|
||||
# Google Service configuration
|
||||
Service_GOOGLE_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
|
||||
Service_GOOGLE_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETDJhbGVQMHlFQzNPVFI1ZzBMa3pNMGlQUHhaQm10eVl1bFlSeTBybzlTOWE2MURXQ0hkRlo0NlNGbHQxWEl1OVkxQnVKYlhhOXR1cUF4T3k0WDdscktkY1oyYllRTmdDTWpfbUdwWGtSd1JvNlYxeTBJdEtaaS1vYnItcW0yaFM=
|
||||
|
||||
# Google Cloud Speech Services configuration
|
||||
Connector_GoogleSpeech_API_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETk5FWWM3Q0JKMzhIYTlyMkhuNjA4NlF4dk82U2NScHhTVGY3UG83NkhfX3RrcWVtWWcyLXRjU1dTT21zWEl6YWRMMUFndXpsUnJOeHh3QThsNDZKRXROTzdXRUdsT0JZajZJNVlfb0gtMXkwWm9DOERPVnpjU0pyUEZfOGJsUnprT3ltMVVhalUyUm9hMUFtZEtHUnJqOGZ4dEZjZm5SWVVTckVCWnY1UkdVSHVmUlgwbnAyc0xDQW84R3ViSko5OHVCVWZRUVNiaG1pVFB6X3EwS0FPd2dUYjhiSmRjcXh2WEZiXzI4SFZqT21tbDduUWRyVWdFZXpmcVM5ZDR0VWtzZnF5UER6cGwwS2JlLV9CSTZ0Z0IyQ1h0YW9TcmhRTXZEckp4bWhmTkt6UTNYMk4zVkpnbUJmaDIxZnoyR2dWTEYwTUFEV0w2eUdUUGpoZk9XRkt4RVF1Z1NPdUpBeTcyWV9PY1Ffd2s0ZEdVekxGekhoeEl4TmNqaXYtbUJuSVdycFducERWdWtZajZnX011Q2w4eE9VMTBqQ1ZxRmdScWhXY1E3WWhzX1JZcHhxam9FbDVPN3Q1MWtrMUZuTUg3LVFQVHp1T1hpQWNDMzEzekVJWk9ybl91YUVjSkFob1VaMi1ONEtuMnRSOEg1S3QybUMwbVZDejItajBLTjM2Zy1hNzZQMW5LLVVDVGdFWm5BZUxNeEFnUkZzU3dxV0lCUlc0LWo4b05GczVpOGZSV2ZxbFBwUml6OU5tYjdnTks3Y3hrVEZVTHlmc1NPdFh4WE5pWldEZklOQUxBbjBpMTlkX3FFQVJ6c2NSZGdzTThycE92VW82enZKamhiRGFnU25aZGlHZHhZd2lUUmhuTVptNjhoWVlJQkxIOEkzbzJNMjZCZFJyM25tdXBnQ2ZWaHV3b2p6UWJpdk9xUEhBc1dyTlNmeF9wbm5yYUhHV01UZnVXWDFlNzBkdXlWUWhvcmJpSmljbmE3LUpUZEg4VzRwZ2JVSjdYUm1sODViQXVxUzdGTmZFbVpiN2V1YW5XV3U4b2VRWmxldGVGVHZsSldoekhVLU9wZ2V0cGZIYkNqM2pXVGctQVAyUm4xTHhpd1VVLXFhcnVEV21Rby1hbTlqTl84TjVveHdYTExUVkhHQ0ltaTB2WXJnY1NQVE5PbWg3ejgySElYc1JSTlQ3NDlFUWR6STZVUjVqaXFRN200NF9LY1ljQ0R2UldlWUtKY1NQVnJ4QXRyYTBGSWVuenhyM0Z0cWtndTd1eG8xRzY5a2dNZ1hkQm5MV3BHVzA2N1QwUkd6WlRGYTZQOUhnVWQ2S0Y5U0s1dXFNVXh5Q2pLWVUxSUQ2MlR1ak52NmRIZ2hlYTk1SGZGWS1RV3hWVU9rR3d1Rk9MLS11REZXbzhqMHpsSm1HYW1jMUNLT29YOHZsRWNaLTVvOFpmT3l3MHVwaERTT0dNLWFjcGRYZ25qT2szTkVFUnRFR3JWYS1aNXFIRnMyalozTlQzNFF2NXJLVHVPVF9zdTF6ZjlkbzJ4RFc2ZENmNFFxZDZzTzhfMUl0bW96V0lPZkh1dXFYZlEteFBlSG84Si1FNS1TTi1OMkFnX2pOYW8xY3MxMVJnVC02MDUyaXZfMEVHWDQtVlRpcENmV0h3V0dCWEFRS2prQXdNRlQ5dnRFVHU0Q1dNTmh0SlBCaU55bFMydWM1TTFFLW96ODBnV3dNZHFZTWZhRURYSHlrdzF3RlRuWDBoQUhSOUJWemtRM3pxcDJFbGJoaTJ3ZktRTlJxbXltaHBoZXVJVDlxS3cxNWo2c0ZBV0NzaUstRWdsMW1xLXFkanZGYUFiU0tSLXFQa0tkcDFoMV9kak41ZjQ0R214UmtOR1ZBanRuemY3Mmw1SkZ5aDZodGIzT3N2aV85MW9kcld6c0g0ZDgtTWo3b3Y3VjJCRnR2U2tMVm9rUXNVRnVHbzZXVTZ6RmI2RkNmajBfMWVnODVFbnpkT0oyci15czJHU0p1cUowTGZJMzVnd3hIRjQyTVhKOGRkcFRKdVpyQ3Yzd01Jb1lSajFmV0paeEV0cjk1SmpmdWpDVFJMUmMtUFctOGhaTmlKQXNRVlVUNlhJemxudHZCR056SVlBb3NOTEYxRTRLaFlVd2d3TWtxVlB6ZEtQLTkxOGMyY3N0a2pYRFUweDBNaGhja2xSSklPOUZla1dKTWRNbG8tUGdSNEV5cW90OWlOZFlIUExBd3U2b2hyS1owbXVMM3p0Qm41cUtzWUxYNzB1N3JpUTNBSGdsT0NuamNTb1lIbXR4MG1sakNPVkxBUXRLVE1xX0YxWDhOcERIY1lTQVFqS01CaXZKNllFaXlIR0JsM1pKMmV1OUo3TGI1WkRaVnYxUTl1LTM0SU1qN1V1b0RCT0x0VHNLTmNLZnk1S0MxYnBBcm03WnVua0xqaEhGUzhOU253ZkppRzdudXBSVlMxeFVOSWxtZ1o2RVBSQUhEUEFuQ1hxSVZMME4yWUtaU3VyRGo3RkUyRUNjT0pNcE1BdE1ZRzdXVl8ydUtXZjdMdHdEVW4teHUtTi1HSGliLUxud21TX0NtcGVkRFBHNkZ1WTlNczR4OUJfUVluc1BoV09oWS1scUdsNnB5d1U5M1huX3k4QzAyNldtb2hybktYN2xKZ1NTNWFsaWwzV3pCRVhkaGR5eTNlV1d6ZzFfaFZTT0E4UjRpQ3pKdEZxUlJ6UFZXM3laUndyWEk2NlBXLUpoajVhZzVwQXpWVzUtVjVNZFBwdWdQa3AxZC1KdGdqNnhibjN4dmFYb2cxcEVwc1g5R09zRUdINUZtOE5QRjVUU0dpZy1QVl9odnFtVDNuWFZLSURtMXlSMlhRNTBWSVFJbEdOOWpfVWV0SmdRWDdlUXZZWE8xRUxDN1I0aEN6MHYwNzM1cmpJS0ZpMnBYWkxfb3FsbEV1VnlqWGxqdVJ6SHlwSjAzRlMycTBaQ295NXNnZERpUnJQcjhrUUd3bkI4bDVzRmxQblhkaFJPTTdISnVUQmhET3BOMTM4bjVvUEc2VmZhb2lrR1FyTUl2RWNEeGg0U0dsNnV6eU5zOUxiNDY5SXBxR0hBS00wOTgyWTFnWkQyaEtLVUloT3ZxZGh0RWVGRmJzenFsaUtfZENQM0JzdkVVeTdXR3hUSmJST1NBMUI1NkVFWncwNW5JZVVLX1p1RXdqVnFfQWpvQ08yQjZhN1NkTkpTSnUxOVRXZXE0WFEtZWxhZW1NNXYtQ2sya0VGLURmS01lMkctNVY3c2ZhN0ZGRFgwWHlabTFkeS1hcUZ1dDZ3cnpPQ3hha2IzVE11M0pqbklmU0diczBqTFBNZC1QZGp6VzNTSnJVSjJoWkJUQjVORG4tYUJmMEJtSUNUdVpEaGt6OTM3TjFOdVhXUHItZjRtZ25nU3NhZC1sVTVXNTRDTmxZbnlfeHNsdkpuMXhUYnE1MnpVQ0ZOclRWM1M4eHdXTzRXbFRZZVQtTS1iRVdXVWZMSGotcWg3MUxUYTFnSEEtanBCRHlZRUNIdGdpUFhsYjdYUndCZnRITzhMZVJ1dHFoVlVNb0duVjlxd0U4OGRuQVV3MG90R0hiYW5MWkxWVklzbWFRNzBfSUNrdzc5bVdtTXg0dExEYnRCaDI3c1I4TWFwLXZKR0wxSjRZYjZIV3ZqZjNqTWhFT0RGSDVMc1A1UzY2bDBiMGFSUy1fNVRQRzRJWDVydUpqb1ZfSHNVbldVeUN2YlAxSW5WVDdxVzJ1WHpLeUdmb0xWMDNHN05oQzY3YnhvUUdhS2xaOHNidkVvbTZtSHFlblhOYmwyR3NQdVJDRUdxREhWdF9ZcXhwUWxHc2hyLW5vUGhIUVhJNUNhY0hFU0ptVnI0TFVhZDE1TFBBUEstSkRoZWJ5MHJhUmZrR1ZrRlFtRGpxS1pOMmFMQjBsdjluY3FiYUU4eGJVVXlZVEpuNWdHVVhJMGtwaTdZR2NDbXd2eHpOQ09SeTV6N1BaVUpsR1pQVDBZcElJUUt6VnVpQmxSYnE4Y1BCWV9IRWdVV0p3enBGVHItdnBGN3NyNWFBWmkySnByWThsbDliSlExQmp3LVlBaDIyZXp6UnR6cU9rTzJmTDBlSVpON0tiWllMdm1oME1zTFl2S2ZYYllhQlY2VHNZRGtHUDY4U1lIVExLZTU4VzZxSTZrZHl1ZTBDc0g4SjI4WGYyZHV1bm9wQ3R2Z09ld1ZmUkN5alJGeHZKSHl1bWhQVXpNMzdjblpLcUhfSm02Qlh5S1FVN3lIcHl0NnlRPT0=
|
||||
|
||||
|
|
|
|||
24
env_int.env
24
env_int.env
|
|
@ -31,9 +31,20 @@ APP_LOGGING_FILE_ENABLED = True
|
|||
APP_LOGGING_ROTATION_SIZE = 10485760
|
||||
APP_LOGGING_BACKUP_COUNT = 5
|
||||
|
||||
# Service Redirects
|
||||
Service_MSFT_REDIRECT_URI = https://gateway-int.poweron-center.net/api/msft/auth/callback
|
||||
Service_GOOGLE_REDIRECT_URI = https://gateway-int.poweron-center.net/api/google/auth/callback
|
||||
# OAuth: Auth app (login/JWT) vs Data app (Graph / Google APIs)
|
||||
Service_MSFT_AUTH_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
|
||||
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_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_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
|
||||
|
||||
# Google Service configuration
|
||||
Service_GOOGLE_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
|
||||
Service_GOOGLE_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNThGeVRNd3hacThtRnE0bzlDa0JPUWQyaEd6QjlFckdsMGZjRlRfUks2bXV3aDdVRTF3LVRlZVY5WjVzSXV4ZGNnX002RDl3dkNYdGFzZkxVUW01My1wTHRCanVCLUozZEx4TlduQlB5MnpvNTR2SGlvbFl1YkhzTEtsSi1SOEo=
|
||||
|
||||
# Google Cloud Speech Services configuration
|
||||
Connector_GoogleSpeech_API_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRkNmVXZ1pWcHcydTF2MXF0ZGJoWHBydF85bTczTktiaEJ3Wk1vMW1mZVhDSG1yd0ZxR2ZuSGJTX0N3MWptWXFJTkNTWjh1SUVVTXI4UDVzcGdLMkU5SHJ2TUpkRlRoRWdnSldtYjNTQkh4UDJHY2xmdTdZQ1ZiMTZZcGZxS3RzaHdjV3dtVkZUcEpJcWx0b2xuQVR6ZmpoVFZPY1hNMTV2SnhDaC1IZEh4UUpLTy1ILXA4RG1zamJTbUJ4X0t2M2NkdzJPbEJxSmFpRzV3WC0wZThoVzlxcmpHZ3ZkLVlVY3REZk1vV19WQ05BOWN6cnJ4MWNYYnNiQ0FQSUVnUlpfM3BhMnlsVlZUOG5wM3pzM1lSN1UzWlZKUXRLczlHbjI1LTFvSUJ4SlVXMy1BNk43bE5Hb0RfTTVlWk9oZnFIaVg0SW5pbm9EcXRTTzU1RFlYY3dTcnpKWWNyNjN5T1BGZ0FmX253cEFncmhvZVRuM05KYzhkOEhFMFJsc2NBSEwzZVZ1R0JMOGxsekVwUE55alZaRXFrdzNWWVNGWXNmbnhKeWhQSFo2VXBTUlRPeHdvdVdncEFuOWgydEtsSUFneUN6cGVaTnBSdjNCdVJseGJFdmlMc203UFhLVlYyTENkaGg2dVN6Z2xwT1ZmTmN5bVZGUkM3ZWcyVkt2ckFUVVd3WFFwYnJjNVRobEh2SkVJbXRwUUpEOFJKQ1NUc0Q4NHNqUFhPSDh5cTV6MEcwSDEwRUJCQ2JiTTJlOE5nd3pMMkJaQ1dVYjMwZVVWWnlETmp2dkZ3aXEtQ29WNkxZTFkzYUkxdTlQUU1OTnhWWU12YU9MVnJQa1d2ZjRtUlhneTNubEMxTmp1eUNPOThSMlB3Y1F0T2tCdFNsNFlKalZPV25yR2QycVBUb096RmZ1V0FTaGsxLV9FWDBmenBIOXpMdGpLcUc0TWRoY2hlMFhYTzlET1ZRekw0ZHNwUVBQdVJBX2h6Q2ZzWVZJWTNybTJiekp3WmhmWF9SUFBXQzlqUjctcVlHWWVMZWVQallzR0JGTVF0WmtnWlg1aTM1bFprNVExZXY5dnNvWF93UjhwbkJ3RzNXaVJ2d2RRU3JJVlBvaVh4eTlBRUtqWkJia3dJQVVBV2Nqdm9FUTRUVW1TaHp2ZUwxT0N2ZndxQ2Nka1RYWXF0LWxIWFE0dTFQcVhncFFPM0hFdUUtYlFnemx3WkF4bjA1aDFULUdrZlVZbEJtRGRCdjJyVkdJSXozd0I0dF9zbWhOeHFqRDA4T1NVaWR5cjBwSVgwbllPU294NjZGTnM1bFhIdGpNQUxFOENWd3FCbGpSRFRmRXotQnU0N2lCVEU5RGF6Qi10S2U2NGdadDlrRjZtVE5oZkw5ZWFjXzhCTmxXQzNFTFgxRXVYY3J3YkxnbnlBSm9PY3h4MlM1NVFQbVNDRW5Ld1dvNWMxSmdoTXJuaE1pT2VFeXYwWXBHZ29MZDVlN2lwUUNIeGNCVVdQVi1rRXdJMWFncUlPTXR0MmZVQ1l0d09mZTdzWGFBWUJMUFd3b0RSOU8zeER2UWpNdzAxS0ZJWnB5S3FJdU9wUDJnTTNwMWw3VFVqVXQ3ZGZnU1RkUktkc0NhUHJ0SGFxZ0lVWDEzYjNtU2JfMGNWM1Y0dHlCTzNESEdENC1jUWF5MVppRzR1QlBNSUJySjFfRi1ENHEwcmJ4S3hQUFpXVHA0TG9DZWdoUlo5WnNSM1lCZm1KbEs2ak1yUUU4Wk9JcVJGUkJwc0NvUkMyTjhoTWxtZmVQeDREZVRKZkhYN2duLVNTeGZzdFdBVnhEandJSXB5QjM0azF0ckI3Tk1wSzFhNGVOUVRrNjU0cG9JQ29pN09xOFkwR1lMTlktaGp4TktxdTVtTnNEcldsV2pEZm5nQWpJc2hxY0hjQnVSWUR5VVdaUXBHWUloTzFZUC1oNzJ4UjZ1dnpLcDJxWEZtQlNIMWkzZ0hXWXdKeC1iLXdZWVJhcU04VFlpMU5pd2ZIdTdCdkVWVFVBdmJuRk16bEFFQTh4alBrcTV2RzliT2hGdTVPOXlRMjFuZktiRTZIamQ1VFVqS0hRTXhxcU1mdkgyQ1NjQmZfcjl4c3NJd0RIeDVMZUFBbHJqdEJxWWl3aWdGUEQxR3ZnMkNGdVB4RUxkZi1xOVlFQXh1NjRfbkFEaEJ5TVZlUGFrWVhSTVRPeGxqNlJDTHNsRWRrei1pYjhnUmZrb3BvWkQ2QXBzYjFHNXZoWU1LSExhLWtlYlJTZlJmYUM5Y1Rhb1pkMVYyWTByM3NTS0VXMG1ybm1BTVN2QXRYaXZqX2dKSkZrajZSS2cyVlNOQnd5Y29zMlVyaWlNbTJEb3FuUFFtbWNTNVpZTktUenFZSl91cVFXZjRkQUZyYmtPczU2S1RKQ19ONGFOTHlwX2hOOEE1UHZEVjhnT0xxRjMxTEE4SHhRbmlmTkZwVXJBdlJDbU5oZS05SzI4QVhEWDZaN2ZiSlFwUGRXSnB5TE9MZV9ia3pYcmZVa1dicG5FMHRXUFZXMWJQVDAwOEdDQzJmZEl0ZDhUOEFpZXZWWXl5Q2xwSmFienNCMldlb2NKb2ZRYV9KbUdHRzNUcjU1VUFhMzk1a2J6dDVuNTl6NTdpM0hGa3k0UWVtbF9pdDVsQVp2cndDLUU5dnNYOF9CLS0ySXhBSFdCSnpqV010bllBb3U0cEZZYVF5R2tSNFM5NlRhdS1fb1NqbDBKMkw0V2N0VEZhNExtQlR3ckZ3cVlCeHVXdXJ6X0s4cEtsaG5rVUxCN2RRbHQxTmcyVFBqYUxyOHJzeFBXVUJaRHpXbUoxdHZzMFBzQk1UTUFvX1pGNFNMNDFvZWdTdEUtMUNKMXNIeVlvQk1CeEdpZVdmN0tsSDVZZHJXSGt5c2o2MHdwSTZIMVBhRzM1eU43Q2FtcVNidExxczNJeUx5U2RuUG5EeHpCTlg2SV9WNk1ET3BRNXFuc0pNWlVvZUYtY21oRGtJSmwxQ09QbHBUV3BuS3B5NE9RVkhfellqZjJUQ0diSV94QlhQWmdaaC1TRWxsMUVWSXB0aE1McFZDZDNwQUVKZ2t5cXRTXzlRZVJwN0pZSnJSV21XMlh0TzFRVEl0c2I4QjBxOGRCYkNxek04a011X1lrb2poQ3h2LUhKTGJiUlhneHp5QWFBcE5nMElkNTVzM3JGOWtUQ19wNVBTaVVHUHFDNFJnNXJaWDNBSkMwbi1WbTdtSnFySkhNQl9ZQjZrR2xDcXhTRExhMmNHcGlyWjR3ZU9SSjRZd1l4ZjVPeHNiYk53SW5SYnZPTzNkd1lnZmFseV9tQ3BxM3lNYVBHT0J0elJnMTByZ3VHemxta0tVQzZZRllmQ2VLZ1ZCNDhUUTc3LWNCZXBMekFwWW1fQkQ1NktzNGFMYUdYTU0xbXprY1FONUNlUHNMY3h2NFJMMmhNa3VNdzF4TVFWQk9odnJUMjFJMVd3Z2N6Sms5aEM2SWlWZFViZ0JWTEpUWWM5NmIzOS1oQmRqdkt1NUUycFlVcUxERUZGbnZqTUxIYnJmMDBHZDEzbnJsWEEzSUo3UmNPUDg1dnRUU1FzcWtjTWZwUG9zM0JTY3RqMDdST2UxcXFTM0d0bGkwdFhnMk5LaUlxNWx3V1pLaVlLUFJXZzBzVl9Ia1V1OHdYUEFWOU50UndycGtCdzM0Q0NQamp2VTNqbFBLaGhsbUk5dUI5MjU5OHVySk1oY0drUWtXUloyVVRvOWJmbUVYRzFVeWNQczh2NXJCeVppRlZiWDNJaDhOSmRmX2lURTNVS3NXQXFZT1QtUmdvMWJoVWYxU3lqUUJhbzEyX3I3TXhwbm9wc1FoQ1ZUTlNBRjMyQTBTY2tzbHZ3RFUtTjVxQ0o1QXRTVks2WENwMGZCRGstNU1jN3FhUFJCQThyaFhhMVRsbnlSRXNGRmt3Yk01X21ldmV3bTItWm1JaGpZQWZROEFtT1d1UUtPQlhYVVFqT2NxLUxQenJHX3JfMEdscDRiMXcyZ1ZmU3NFMzVoelZJaDlvT0ZoRGQ2bmtlM0M5ZHlCd2ZMbnRZRkZUWHVBUEx4czNfTmtMckh5eXZrZFBzOEItOGRYOEhsMzBhZ0xlOWFjZzgteVBsdnpPT1pYdUxnbFNXYnhKaVB6QUxVdUJCOFpvU2x2c1FHZV94MDBOVWJhYkxISkswc0U5UmdPWFJLXzZNYklHTjN1QzRKaldKdEVHb0pOU284N3c2LXZGMGVleEZ5NGZ6OGV1dm1tM0J0aTQ3VFlNOEJrdEh3PT0=
|
||||
|
||||
|
|
|
|||
24
env_prod.env
24
env_prod.env
|
|
@ -31,9 +31,20 @@ APP_LOGGING_FILE_ENABLED = True
|
|||
APP_LOGGING_ROTATION_SIZE = 10485760
|
||||
APP_LOGGING_BACKUP_COUNT = 5
|
||||
|
||||
# Service Redirects
|
||||
Service_MSFT_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/msft/auth/callback
|
||||
Service_GOOGLE_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/google/auth/callback
|
||||
# OAuth: Auth app (login/JWT) vs Data app (Graph / Google APIs)
|
||||
Service_MSFT_AUTH_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
|
||||
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_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_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
|
||||
|
||||
# Google Service configuration
|
||||
Service_GOOGLE_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
|
||||
Service_GOOGLE_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3eWFwSEZ4YnRJcjU1OW5kcXZKdkt1Z3gzWDFhVW5Eelh3VnpnNlppcWxweHY5UUQzeDIyVk83cW1XNVE4bllVWnR2MjlSQzFrV1UyUVV6OUt5b3Vqa3QzMUIwNFBqc2FVSXRxTlQ1OHVJZVFibnhBQ2puXzBwSXp5NUZhZjM1d1o=
|
||||
|
||||
# Google Cloud Speech Services configuration
|
||||
Connector_GoogleSpeech_API_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z4NFQxaF9uN3h1cVB6dnZid1c1R1VfNDlSQ1NHMEVDZWtKanpMQ29CLXc1MXBqRm1hQ0YtWVhaejBMY1ZTOEFEVlpWQ3hrYkFza1E2RDNsYkdMMndNR0VGNTMwVDRGdURJY3hyaVFxVjEtSEYwNHJzeWM3WmlpZW9jU2E3NTgycEV2allqQ3dJRTNyRFAzaDJ6dklKeXpNRkJhYjFzUkptN2dpbkNpMklrcGxuZl9vTkt3T0JvNm1YTXd5UlkwZWptUXdWVFpnV2J4X3J2WUhIUlFkSElFVnlqMnlJRnNHTnlpMWs2R1dZc2ROWjNYZG85cndmd1E5cUZnVmZRYnVjTG43dXFmSWd2bGFfVWFWSmtpWkpndWNlSUNwcnFNU2NqZXFaV0xsY3l3SElLRkVHcHZGZERKV1ltcGhTS0dhTko1VTJLYzNoZjRkSGVEX3dTMWVVTmdDczV5cE1JQUdSbUJGUm11eFhTVjJHbkt0SzB4UG1Dc2xmbnp1Y041Y2RTeWRuWGdmQy1sTGx0MGtnM2VJQ3EyLXViRlNhTU9ybzZkR1N1bXE5SXhlZENWRFpWSGlYOWx4SUQ3UlR0ZEVxQkxNakRUVFRiUmFnbklOalphLUZkRFVVaXBRUk5NZW5PaUZydTFmQkNPSTdTVTNZd0plWXllNVFJdmN4MVcyTGlwMGFtVjBzOGRxR1FjbzhfYW5zdTB0ZEZBTTJhakltazh1dktNMUZsOUItdFdTb1pIaUxySllXNkdlY20zUS0wTnpFNTB2SU5acG1VcXhyaHBmME8takw3RDh5T043T2VGOV92TzNya2pWSlpYVjZDdXlZcjM3a0hPTlhkaW9oQmxqQlpGRFYyTTY4WmZmT3k4Tk1tdXRuSGdTUVpNT2NKenhXb05PdXBfSEdhMTNxNjdpNXlKUUI2YUgydFFPX1VvXzVJb0UxWTU2YVNiNDQ0QndZanhMMHR1cGdHWGhvcEg1QXEtSXZJdTdZUE12ZEVVWkF4QmtsQS1GYnY3SFIxSHlsOGVfcEpGS1A4QUVEQWNEOFZYYlljQ3ByTU03YU16Y0UzUnJQZEprSWNjT1ZXVEtDWi03Y3ZzRVdYUTlabXJISEo5THRHVXVuM0xqbzA4bGVlZVpOMk1QMmptb21tV0pTMlVoOXdWVU95UW1iQmttc2w1RG9mMWwxXzg1T2IxYUVmTUJEZkpUdTFDTzZ3RlBFeUFiX01iRTZNWkNaSG45TkFOM2pzbUJRZ2N0VFpoejJUTG1RODY3TzZpSzVkYUQzaEpfY2pSTkRzU0VpanlkdXVQQmJ2WU5peno4QWNLTDVxZTlhSHI3NnNiM0k0Y3JkQ0xaOU05bGtsQl8zQklvaktWSDZ4aVp2MHlYelJuUDJyTU9CZC1OZjJxNFc1dDcwSUlxaVh1LTMyWWFwU0IwUU9kOUFpMWpnOERtLTh1VmJiNGVwcXBMbU5fMjVZc0hFbmxQT2puSFd1ZGpyTkphLU5sVlBZWWxrWEZrWGJQWmVkN19tZFZfZ1l1V3pSWlA0V0ZxM2lrWnl2NU9WeTdCbDROSmhfeENKTFhMVXk1d195S2JMUFJoRXZjcVo4V2g0MTNKRnZhUE1wRkNPM3FZOGdVazJPeW5PSGpuZnFGTTdJMkRnam5rUlV6NFlqODlIelRYaEN5VjdJNnVwbllNODNCTFRHMWlXbmM1VlRxbXB3Wm9LRjVrQUpjYzRNMThUMWwwSVhBMUlyamtPZnE4R0o4bEdHay1zMjR5RDJkZ1lYRHZaNHVHU2otR3ZpN25LZlEySEU0UmdTNzJGVHNWQXMyb0dVMV9WUE13ODhZWUFaakxGOWZieGNXZkNYRnV5djEyWTZLcmdrajRBLU1rS1Z0VVRkOWlDMU9fMGVmYXFhZXJGMUhpNkdmb2hkbzZ1OWV6VlNmVzNISjVYTFh6SjJNdWR5MWZidE8yVEo2dnRrZXhMRXBPczUwTG13OGhNUVpIQm0zQmRKRnJ0Nl8wNW1Ob0dHRDVpU0NWREV3TkY2SjktdVBkMFU1ZXBmSFpHQ3FHNTRZdTJvaExpZVEtLTU4YTVyeFBpNDdEajZtWUc4c1dBeUJqQ3NIY1NLS0FIMUxGZzZxNFNkOG9ORGNHWWJCVnZuNnJVTEtoQi1mRTZyUl81ZWJJMi1KOGdERzBhNVRZeHRYUUlqY2JvMFlaNHhWMU9pWFFiZjdaLUhkaG15TTBPZVlkS2R5UVdENTI4QVFiY1RJV0ZNZnlpVWxfZmlnN1BXbGdrbjFGUkhzYl9qeHBxVVJacUE4bjZETENHVFpSamh0NVpOM2hMYTZjYzBuS3J0a3hhZGxSM1V5UHd2OTU3ZHY0Yy1xWDBkWUk0Ymp0MWVrS3YzSktKODhQZnY3QTZ1Wm1VZkZJbS1jamdreks1ZlhpQjFOUDFiOHJ2Nm9NcmdTdU5LQXV2RkZWZEFNZnVKUjVwcVY3dDdhQnpmRVJ6SmlvVXpDM0ZiYXh5bGE2X04tTE9qZ3BiTnN3TF9ZaFRxSUpjNjB1dXZBcy1TZHRHTjFjSUR3WUl4cE9VNzB5Rkk4U3Z1SVZYTl9sYXlZVk83UnFrMlVmcnBpam9lRUlCY19DdVJwOXl2TVVDV1pMRFZTZk9MY3Z1eXA0MnhGazc5YllQaWtOeTc4NjlOa2lGY05RRzY1cG9nbGpYelc4c3FicWxWRkg0YzRSamFlQ19zOU14YWJreU9pNDREZVJ3a0REMUxGTzF1XzI1bEF3VXVZRjlBeWFiLXJsOXgza3VZem1WckhWSnVNbDBNcldadU8xQ3RwOTl5NGgtVlR0QklCLWl5WkE4V1FlQTBCOVU1RE9sQlRrYUNZOGdfUmEwbEZvUTFGUEFWVmQ4V1FhOU9VNjZqemRpZm1sUDhZQTJ0YVBRbWZldkF5THV4QXpfdUtNZ0tlcGdSRFM3c0lDOTNQbnBxdmxYYWNpTmI3MW9BMlZIdTQ5RldudHpNQWQ5NDNPLVVTLXVVNzdHZXh4UXpZa3dVa2J4dTFDV1RkYjRnWXU2M3lJekRYWGNMcWU5OVh6U2xZWDh6MmpqcnpiOHlnMjA5S3RFQm1NZjNSM21adkVnTUpSYVhkTzNkNnJCTmljY0x1cl9kMkx3UHhySjZEdHREanZERzNEUTFlTkR0NWlBczAtdmFGTjdZNVpTMlkxV2czYW5RN2lqemg4eUViZDV6RjdKNXdFcUlvcVhoNkJ6eVJkR1pua1hnNzQwOEs2TXJYSlpGcW9qRDU2QjBOWFFtdXBJRkRKbmdZUF9ZSmRPVEtvUjVhLTV1NjdXQjRhS0duaEtJb2FrQnNjUTRvdFMxdkdTNk1NYlFHUFhhYTJ1eUN3WHN4UlJ4UjdrZjY0SzFGYWVFN1k0cGJnc1RjNmFUenR4NHljbVhablZSWHZmUVN3cXRHNjhsX1BSZWEzdTJUZFA0S2pTaU9YMnZIQ1ZPcGhWMFJqZkVEMWRMR1h3SnU0Z2FzZ3VGM3puNzdhVjhaQXNIWHFsbjB0TDVYSFdSNV9rdWhUUUhSZHBGYkJIVDB5SDdlMC13QTVnS0g5Qkg5RGNxSGJlelVndUhPcEQ0QkRKMTJTZUM1OXJhVm0zYjU0OVY2dk9MQVBheklIQXpVNW9Yc0ROVjEzaFZTWmVxYlBWMlNlSzladzJ6TmNuMG5FVVZkN1VZN1pfS2ZHa0lQcE80S24wSnQtVlJVV09OVWJ3M09YMkZpV2ktVF9ENHhKU2dfYUQ2aUVyamk0VHJHQmVfVHU4clpUTFoteW5aSWRPV1M0RDRMTms4NGRoYmJfVE82aUl2X3VieVJOdDhBQmRwdzdnRTVBNzZwaW93dUlZb3ZRYUtOeG9ULWxvNVp5a0haSjdkcUhRb3d6UGIxRUpCVkVYX2d6TkRqQVozUWxkNGFoc1FXYVd2YWNkME9Qclo0bjYxMFRWTy1nbnI5NTBJNzRMMDluUXRKYTFqQUN4d0d5aHVlamN3Tkk3NWJXeXR0TW9BeUg5Vnp4Q2RnZUY3b3AtMDlrNmlrSGR0eGRtbUdUd2lFRWg4MklEeWJHN2wwZEpVSXMxNDNOWjRFS0tPdWxhMmFCckhfRENIY184aEFDZXNrRDl2dHQtQW12UnRuQXJjaDJoTUpiYkNWQUtfRG9GMUZoNWM4UnBYZ29RWWs2NHcyUm5kdTF3Vk1GeFpiRUJLaVZ2UGFjbi1jV3lMV0N2ZDl4VERPN295X01NNG56ZjZkRzZoYUtmY1E5NlVXemx2SnVfb19iSXg0R2M3Mjd1a2JRPT0=
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ from modules.shared.configuration import APP_CONFIG
|
|||
from modules.security.rootAccess import getRootDbAppConnector, getRootUser
|
||||
from modules.interfaces.interfaceDbApp import getInterface, getRootInterface
|
||||
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
|
||||
|
||||
# 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}"
|
||||
)
|
||||
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:
|
||||
# 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)):
|
||||
logger.info("Local JWT without server record or missing authority claim")
|
||||
if normalized_authority in (
|
||||
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
|
||||
except HTTPException:
|
||||
raise
|
||||
|
|
|
|||
|
|
@ -24,10 +24,17 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
|||
self.exempt_paths = exempt_paths or {
|
||||
"/api/local/login",
|
||||
"/api/local/register",
|
||||
"/api/msft/login",
|
||||
"/api/google/login",
|
||||
"/api/msft/callback",
|
||||
"/api/google/callback",
|
||||
# OAuth Auth app + Data app (GET redirects / callbacks)
|
||||
"/api/msft/auth/login",
|
||||
"/api/msft/auth/login/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)
|
||||
}
|
||||
|
||||
|
|
|
|||
42
modules/auth/oauthProviderConfig.py
Normal file
42
modules/auth/oauthProviderConfig.py
Normal 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)
|
||||
|
|
@ -9,10 +9,11 @@ import logging
|
|||
import httpx
|
||||
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.shared.configuration import APP_CONFIG
|
||||
from modules.shared.timeUtils import getUtcTimestamp, createExpirationTimestamp, parseTimestamp
|
||||
from modules.auth.oauthProviderConfig import msftDataScopesForRefresh
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -20,14 +21,14 @@ class TokenManager:
|
|||
"""Centralized token management service"""
|
||||
|
||||
def __init__(self):
|
||||
# Microsoft OAuth configuration
|
||||
self.msft_client_id = APP_CONFIG.get("Service_MSFT_CLIENT_ID")
|
||||
self.msft_client_secret = APP_CONFIG.get("Service_MSFT_CLIENT_SECRET")
|
||||
# Microsoft Data-app OAuth (refresh + token exchange for connections)
|
||||
self.msft_client_id = APP_CONFIG.get("Service_MSFT_DATA_CLIENT_ID")
|
||||
self.msft_client_secret = APP_CONFIG.get("Service_MSFT_DATA_CLIENT_SECRET")
|
||||
self.msft_tenant_id = APP_CONFIG.get("Service_MSFT_TENANT_ID", "common")
|
||||
|
||||
# Google OAuth configuration
|
||||
self.google_client_id = APP_CONFIG.get("Service_GOOGLE_CLIENT_ID")
|
||||
self.google_client_secret = APP_CONFIG.get("Service_GOOGLE_CLIENT_SECRET")
|
||||
# Google Data-app OAuth
|
||||
self.google_client_id = APP_CONFIG.get("Service_GOOGLE_DATA_CLIENT_ID")
|
||||
self.google_client_secret = APP_CONFIG.get("Service_GOOGLE_DATA_CLIENT_SECRET")
|
||||
|
||||
def refreshMicrosoftToken(self, refreshToken: str, userId: str, oldToken: Token) -> Optional[Token]:
|
||||
"""Refresh Microsoft OAuth token using refresh token"""
|
||||
|
|
@ -49,7 +50,7 @@ class TokenManager:
|
|||
"client_secret": self.msft_client_secret,
|
||||
"grant_type": "refresh_token",
|
||||
"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})")
|
||||
|
||||
|
|
@ -68,6 +69,7 @@ class TokenManager:
|
|||
userId=userId,
|
||||
authority=AuthAuthority.MSFT,
|
||||
connectionId=oldToken.connectionId, # Preserve connection ID
|
||||
tokenPurpose=TokenPurpose.DATA_CONNECTION,
|
||||
tokenAccess=tokenData["access_token"],
|
||||
tokenRefresh=tokenData.get("refresh_token", refreshToken), # Keep old refresh token if new one not provided
|
||||
tokenType=tokenData.get("token_type", "bearer"),
|
||||
|
|
@ -128,6 +130,7 @@ class TokenManager:
|
|||
userId=userId,
|
||||
authority=AuthAuthority.GOOGLE,
|
||||
connectionId=oldToken.connectionId, # Preserve connection ID
|
||||
tokenPurpose=TokenPurpose.DATA_CONNECTION,
|
||||
tokenAccess=tokenData["access_token"],
|
||||
tokenRefresh=tokenData.get("refresh_token", refreshToken), # Use new refresh token if provided
|
||||
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: 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
|
||||
# Only allow a new refresh if at least 10 minutes passed since the token was created/refreshed
|
||||
try:
|
||||
|
|
@ -266,6 +278,16 @@ class TokenManager:
|
|||
token = interface.getConnectionToken(connectionId)
|
||||
if not token:
|
||||
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(
|
||||
token,
|
||||
secondsBeforeExpiry=secondsBeforeExpiry,
|
||||
|
|
|
|||
|
|
@ -11,11 +11,13 @@ import uuid
|
|||
|
||||
|
||||
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_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):
|
||||
|
|
@ -46,30 +48,6 @@ class PeriodTypeEnum(str, Enum):
|
|||
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):
|
||||
"""Billing account for mandate or user-mandate combination."""
|
||||
id: str = Field(
|
||||
|
|
@ -79,7 +57,6 @@ class BillingAccount(BaseModel):
|
|||
userId: Optional[str] = Field(None, description="Foreign key to User (only for PREPAY_USER)")
|
||||
accountType: AccountTypeEnum = Field(..., description="Account type: MANDATE or USER")
|
||||
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")
|
||||
lastWarningAt: Optional[datetime] = Field(None, description="Last warning sent timestamp")
|
||||
enabled: bool = Field(default=True, description="Account is active")
|
||||
|
|
@ -94,7 +71,6 @@ registerModelLabels(
|
|||
"userId": {"en": "User ID", "de": "Benutzer-ID"},
|
||||
"accountType": {"en": "Account Type", "de": "Kontotyp"},
|
||||
"balance": {"en": "Balance (CHF)", "de": "Guthaben (CHF)"},
|
||||
"creditLimit": {"en": "Credit Limit (CHF)", "de": "Kreditlimit (CHF)"},
|
||||
"warningThreshold": {"en": "Warning Threshold (CHF)", "de": "Warnschwelle (CHF)"},
|
||||
"lastWarningAt": {"en": "Last Warning", "de": "Letzte Warnung"},
|
||||
"enabled": {"en": "Enabled", "de": "Aktiv"},
|
||||
|
|
@ -161,15 +137,17 @@ class BillingSettings(BaseModel):
|
|||
billingModel: BillingModelEnum = Field(..., description="Billing model")
|
||||
|
||||
# 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")
|
||||
blockOnZeroBalance: bool = Field(default=True, description="Block AI features when balance is zero")
|
||||
|
||||
# Billing address (required for CREDIT_POSTPAY)
|
||||
billingAddress: Optional[BillingAddress] = Field(None, description="Billing address")
|
||||
|
||||
# Notifications
|
||||
notifyEmails: List[str] = Field(default_factory=list, description="Email addresses for billing notifications")
|
||||
# Notifications (e.g. mandate owner / finance — also used when PREPAY_MANDATE pool is exhausted)
|
||||
notifyEmails: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="Email addresses for billing alerts (mandate pool exhausted, warnings, etc.)",
|
||||
)
|
||||
notifyOnWarning: bool = Field(default=True, description="Send email when warning threshold is reached")
|
||||
|
||||
|
||||
|
|
@ -180,11 +158,15 @@ registerModelLabels(
|
|||
"id": {"en": "ID", "de": "ID"},
|
||||
"mandateId": {"en": "Mandate ID", "de": "Mandanten-ID"},
|
||||
"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 (%)"},
|
||||
"blockOnZeroBalance": {"en": "Block on Zero Balance", "de": "Bei 0 blockieren"},
|
||||
"billingAddress": {"en": "Billing Address", "de": "Rechnungsadresse"},
|
||||
"notifyEmails": {"en": "Notification Emails", "de": "Benachrichtigungs-Emails"},
|
||||
"notifyEmails": {
|
||||
"en": "Billing notification emails (owner / admin)",
|
||||
"de": "E-Mails für Billing-Alerts (Inhaber/Admin)",
|
||||
},
|
||||
"notifyOnWarning": {"en": "Notify on Warning", "de": "Bei Warnung benachrichtigen"},
|
||||
},
|
||||
)
|
||||
|
|
@ -257,7 +239,6 @@ class BillingBalanceResponse(BaseModel):
|
|||
currency: str = "CHF"
|
||||
warningThreshold: float
|
||||
isWarning: bool
|
||||
creditLimit: Optional[float] = None
|
||||
|
||||
|
||||
class BillingStatisticsChartData(BaseModel):
|
||||
|
|
@ -285,3 +266,16 @@ class BillingCheckResult(BaseModel):
|
|||
currentBalance: Optional[float] = None
|
||||
requiredAmount: Optional[float] = 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
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ Multi-Tenant Design:
|
|||
- Mandant-Kontext wird per Request-Header (X-Mandate-Id) bestimmt
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
from typing import Optional, Any
|
||||
from pydantic import BaseModel, Field, ConfigDict, model_validator
|
||||
from modules.shared.attributeUtils import registerModelLabels
|
||||
from modules.shared.timeUtils import getUtcTimestamp
|
||||
from .datamodelUam import AuthAuthority
|
||||
|
|
@ -23,6 +23,13 @@ class TokenStatus(str, Enum):
|
|||
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):
|
||||
"""
|
||||
Authentication Token model.
|
||||
|
|
@ -38,6 +45,10 @@ class Token(BaseModel):
|
|||
connectionId: Optional[str] = Field(
|
||||
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
|
||||
tokenType: str = "bearer"
|
||||
expiresAt: float = Field(
|
||||
|
|
@ -65,6 +76,22 @@ class Token(BaseModel):
|
|||
|
||||
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(
|
||||
"Token",
|
||||
|
|
@ -74,6 +101,7 @@ registerModelLabels(
|
|||
"userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"},
|
||||
"authority": {"en": "Authority", "de": "Autorität", "fr": "Autorité"},
|
||||
"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"},
|
||||
"tokenType": {"en": "Token Type", "de": "Token-Typ", "fr": "Type de jeton"},
|
||||
"expiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"},
|
||||
|
|
|
|||
|
|
@ -1221,10 +1221,28 @@ def _preflight_billing_check(services, mandateId: str, featureInstanceId: Option
|
|||
try:
|
||||
balanceCheck = billingService.checkBalance(0.01)
|
||||
if not balanceCheck.allowed:
|
||||
raise BillingService.InsufficientBalanceException(
|
||||
currentBalance=balanceCheck.currentBalance or 0.0,
|
||||
requiredAmount=0.01,
|
||||
message=f"Ungenuegendes Guthaben. Aktuell: CHF {balanceCheck.currentBalance:.2f}"
|
||||
mid = str(getattr(services, "mandateId", None) or mandateId or "")
|
||||
from modules.datamodels.datamodelBilling import BillingModelEnum
|
||||
from modules.serviceCenter.services.serviceBilling.billingExhaustedNotify import (
|
||||
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()
|
||||
if not rbacAllowedProviders:
|
||||
|
|
|
|||
|
|
@ -110,14 +110,15 @@ TEMPLATE_ROLES = [
|
|||
{
|
||||
"roleLabel": "workspace-admin",
|
||||
"description": {
|
||||
"en": "Workspace Admin - Full access to AI workspace",
|
||||
"de": "Workspace Admin - Vollzugriff auf AI Workspace",
|
||||
"fr": "Administrateur Workspace - Acces complet au workspace AI"
|
||||
"en": "Workspace Admin - All UI and API actions; data is always scoped to own records (same privacy as users)",
|
||||
"de": "Workspace Admin - Alle UI- und API-Aktionen; Daten immer nur eigene Datensätze (gleiche Privatsphäre wie User)",
|
||||
"fr": "Administrateur Workspace - Toute l'UI et les API; donnees limitees a ses propres enregistrements"
|
||||
},
|
||||
"accessRules": [
|
||||
{"context": "UI", "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"},
|
||||
]
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ from fastapi.responses import StreamingResponse, JSONResponse
|
|||
from pydantic import BaseModel, Field
|
||||
|
||||
from modules.auth import limiter, getRequestContext, RequestContext
|
||||
from modules.serviceCenter.services.serviceBilling.mainServiceBilling import (
|
||||
InsufficientBalanceException,
|
||||
)
|
||||
from modules.interfaces import interfaceDbChat, interfaceDbManagement
|
||||
from modules.interfaces.interfaceAiObjects import AiObjects
|
||||
from modules.serviceCenter.core.serviceStreaming import get_event_manager
|
||||
|
|
@ -581,6 +584,15 @@ async def _runWorkspaceAgent(
|
|||
})
|
||||
|
||||
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)
|
||||
await eventManager.emit_event(queueId, "error", {
|
||||
"type": "error",
|
||||
|
|
|
|||
|
|
@ -1962,6 +1962,8 @@ def _createStoreResourceRules(db: DatabaseConnector) -> None:
|
|||
storeResources = [
|
||||
"resource.store.automation",
|
||||
"resource.store.teamsbot",
|
||||
"resource.store.workspace",
|
||||
"resource.store.commcoach",
|
||||
]
|
||||
|
||||
storeRules = []
|
||||
|
|
@ -1998,7 +2000,7 @@ def _createStoreResourceRules(db: DatabaseConnector) -> None:
|
|||
def initRootMandateBilling(mandateId: str) -> None:
|
||||
"""
|
||||
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).
|
||||
|
||||
Args:
|
||||
|
|
@ -2007,7 +2009,12 @@ def initRootMandateBilling(mandateId: str) -> None:
|
|||
try:
|
||||
from modules.interfaces.interfaceDbBilling import _getRootInterface
|
||||
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()
|
||||
appInterface = getAppRootInterface()
|
||||
|
|
@ -2020,27 +2027,28 @@ def initRootMandateBilling(mandateId: str) -> None:
|
|||
settings = BillingSettings(
|
||||
mandateId=mandateId,
|
||||
billingModel=BillingModelEnum.PREPAY_USER,
|
||||
defaultUserCredit=10.0,
|
||||
defaultUserCredit=DEFAULT_USER_CREDIT_CHF,
|
||||
warningThresholdPercent=10.0,
|
||||
blockOnZeroBalance=True,
|
||||
notifyOnWarning=True
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
# Always create user accounts for all users (audit trail)
|
||||
if existingSettings:
|
||||
billingModel = existingSettings.get("billingModel", "UNLIMITED")
|
||||
if billingModel == BillingModelEnum.UNLIMITED.value:
|
||||
return # No accounts needed for UNLIMITED
|
||||
billingModel = parseBillingModelFromStoredValue(
|
||||
existingSettings.get("billingModel")
|
||||
).value
|
||||
|
||||
# Initial balance depends on billing model
|
||||
if billingModel == BillingModelEnum.PREPAY_USER.value:
|
||||
initialBalance = existingSettings.get("defaultUserCredit", 10.0)
|
||||
initialBalance = float(existingSettings.get("defaultUserCredit", 0.0))
|
||||
else:
|
||||
initialBalance = 0.0 # PREPAY_MANDATE / CREDIT_POSTPAY: budget on pool
|
||||
initialBalance = 0.0 # PREPAY_MANDATE: budget on pool account
|
||||
|
||||
userMandates = appInterface.getUserMandatesByMandate(mandateId)
|
||||
accountsCreated = 0
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ from modules.datamodels.datamodelRbac import (
|
|||
Role,
|
||||
)
|
||||
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.datamodelMembership import (
|
||||
UserMandate,
|
||||
|
|
@ -687,11 +687,17 @@ class AppObjects:
|
|||
externalUsername: str = None,
|
||||
externalEmail: str = None,
|
||||
isSysAdmin: bool = False,
|
||||
addExternalIdentityConnection: bool = True,
|
||||
) -> User:
|
||||
"""
|
||||
Create a new user.
|
||||
|
||||
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:
|
||||
# Ensure username is a string
|
||||
|
|
@ -727,8 +733,9 @@ class AppObjects:
|
|||
if not createdRecord or not createdRecord.get("id"):
|
||||
raise ValueError("Failed to create user record")
|
||||
|
||||
# Add external connection if provided
|
||||
if externalId and externalUsername:
|
||||
# Optional: mirror external IdP identity into UserConnections (data/API OAuth).
|
||||
# Auth-only login (Google/MSFT JWT) must NOT create a connection — see OAuth split.
|
||||
if addExternalIdentityConnection and externalId and externalUsername:
|
||||
self.addUserConnection(
|
||||
createdRecord["id"],
|
||||
authenticationAuthority,
|
||||
|
|
@ -746,7 +753,7 @@ class AppObjects:
|
|||
|
||||
# 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"]
|
||||
self._assignUserToRootMandate(userId)
|
||||
|
||||
|
|
@ -815,7 +822,7 @@ class AppObjects:
|
|||
|
||||
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.
|
||||
|
||||
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")
|
||||
return
|
||||
|
||||
# Find the mandate-instance 'viewer' role (bound to this mandate, not a global template)
|
||||
mandateViewerRoles = self.db.getRecordset(
|
||||
# Mandate-instance 'user' role (bound to this mandate, not a global template)
|
||||
mandateUserRoles = self.db.getRecordset(
|
||||
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)
|
||||
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:
|
||||
# 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.
|
||||
User accounts are always created for all billing models (for audit trail).
|
||||
Initial balance depends on billing model:
|
||||
- PREPAY_USER: defaultUserCredit from settings
|
||||
- PREPAY_MANDATE / CREDIT_POSTPAY: 0.0 (budget is on mandate pool)
|
||||
- PREPAY_USER: defaultUserCredit from mandate BillingSettings when joining the root mandate (missing key => 0.0);
|
||||
other mandates get 0.0.
|
||||
- PREPAY_MANDATE: 0.0 on the user account (shared pool — no per-user start credit)
|
||||
|
||||
Args:
|
||||
userId: User ID
|
||||
|
|
@ -1650,7 +1658,7 @@ class AppObjects:
|
|||
"""
|
||||
try:
|
||||
from modules.interfaces.interfaceDbBilling import _getRootInterface as getBillingRootInterface
|
||||
from modules.datamodels.datamodelBilling import BillingModelEnum
|
||||
from modules.datamodels.datamodelBilling import BillingModelEnum, parseBillingModelFromStoredValue
|
||||
|
||||
billingInterface = getBillingRootInterface()
|
||||
settings = billingInterface.getSettings(mandateId)
|
||||
|
|
@ -1658,18 +1666,22 @@ class AppObjects:
|
|||
if not settings:
|
||||
return # No billing configured for this mandate
|
||||
|
||||
billingModel = settings.get("billingModel", "UNLIMITED")
|
||||
if billingModel == BillingModelEnum.UNLIMITED.value:
|
||||
return # No accounts needed for UNLIMITED
|
||||
billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
|
||||
|
||||
# Initial balance depends on billing model
|
||||
if billingModel == BillingModelEnum.PREPAY_USER.value:
|
||||
initialBalance = settings.get("defaultUserCredit", 10.0)
|
||||
# Initial balance depends on billing model (start credit only on root mandate for PREPAY_USER)
|
||||
rootMandateId = self._getRootMandateId()
|
||||
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:
|
||||
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)
|
||||
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:
|
||||
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).
|
||||
CASCADE will delete UserMandateRole entries.
|
||||
Also removes FeatureAccess rows for any feature instances that belong to this mandate
|
||||
(FeatureAccessRole rows cascade from FeatureAccess).
|
||||
|
||||
Args:
|
||||
userId: User ID
|
||||
|
|
@ -1691,6 +1705,24 @@ class AppObjects:
|
|||
if not existing:
|
||||
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)
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting UserMandate: {e}")
|
||||
|
|
@ -2544,6 +2576,16 @@ class AppObjects:
|
|||
"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
|
||||
if not self.currentUser or not self.currentUser.id:
|
||||
raise ValueError("No valid user context available for token storage")
|
||||
|
|
@ -2566,6 +2608,7 @@ class AppObjects:
|
|||
"userId": self.currentUser.id,
|
||||
"authority": token.authority,
|
||||
"connectionId": None, # Ensure we only delete access tokens
|
||||
"tokenPurpose": TokenPurpose.AUTH_SESSION.value,
|
||||
},
|
||||
)
|
||||
deleted_count = 0
|
||||
|
|
@ -2611,6 +2654,16 @@ class AppObjects:
|
|||
"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
|
||||
if not self.currentUser or not self.currentUser.id:
|
||||
raise ValueError("No valid user context available for token storage")
|
||||
|
|
@ -2748,6 +2801,7 @@ class AppObjects:
|
|||
authority: AuthAuthority,
|
||||
sessionId: str = None,
|
||||
mandateId: str = None,
|
||||
tokenPurpose: str = None,
|
||||
) -> Optional[Token]:
|
||||
"""Find an active access token by its id (jti) with optional session/tenant scoping."""
|
||||
try:
|
||||
|
|
@ -2763,6 +2817,8 @@ class AppObjects:
|
|||
recordFilter["sessionId"] = sessionId
|
||||
if mandateId is not None:
|
||||
recordFilter["mandateId"] = mandateId
|
||||
if tokenPurpose is not None:
|
||||
recordFilter["tokenPurpose"] = tokenPurpose
|
||||
tokens = self.db.getRecordset(Token, recordFilter=recordFilter)
|
||||
if not tokens:
|
||||
return None
|
||||
|
|
@ -3405,6 +3461,10 @@ def getInterface(currentUser: User, mandateId: Optional[str] = None) -> AppObjec
|
|||
instance = AppObjects(currentUser)
|
||||
instance.setUserContext(currentUser, mandateId=effectiveMandateId)
|
||||
_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]
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ from modules.datamodels.datamodelBilling import (
|
|||
BillingSettings,
|
||||
StripeWebhookEvent,
|
||||
UsageStatistics,
|
||||
BillingAddress,
|
||||
BillingModelEnum,
|
||||
AccountTypeEnum,
|
||||
TransactionTypeEnum,
|
||||
|
|
@ -31,10 +30,49 @@ from modules.datamodels.datamodelBilling import (
|
|||
PeriodTypeEnum,
|
||||
BillingBalanceResponse,
|
||||
BillingCheckResult,
|
||||
parseBillingModelFromStoredValue,
|
||||
)
|
||||
|
||||
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
|
||||
_billingInterfaces: Dict[str, "BillingObjects"] = {}
|
||||
|
||||
|
|
@ -121,6 +159,8 @@ class BillingObjects:
|
|||
"""
|
||||
Get billing settings for a mandate.
|
||||
|
||||
Normalizes billingModel for API (legacy UNLIMITED → PREPAY_MANDATE) and persists once.
|
||||
|
||||
Args:
|
||||
mandateId: Mandate ID
|
||||
|
||||
|
|
@ -132,7 +172,29 @@ class BillingObjects:
|
|||
BillingSettings,
|
||||
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:
|
||||
logger.error(f"Error getting billing settings: {e}")
|
||||
return None
|
||||
|
|
@ -148,11 +210,6 @@ class BillingObjects:
|
|||
Created settings dict
|
||||
"""
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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.
|
||||
|
||||
|
|
@ -186,10 +243,9 @@ class BillingObjects:
|
|||
settings = BillingSettings(
|
||||
mandateId=mandateId,
|
||||
billingModel=defaultModel,
|
||||
defaultUserCredit=10.0,
|
||||
defaultUserCredit=0.0,
|
||||
warningThresholdPercent=10.0,
|
||||
blockOnZeroBalance=True,
|
||||
notifyOnWarning=True
|
||||
notifyOnWarning=True,
|
||||
)
|
||||
return self.createSettings(settings)
|
||||
|
||||
|
|
@ -365,7 +421,7 @@ class BillingObjects:
|
|||
def ensureAllMandateSettingsExist(self) -> int:
|
||||
"""
|
||||
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.
|
||||
|
||||
Returns:
|
||||
|
|
@ -397,11 +453,10 @@ class BillingObjects:
|
|||
# Create default billing settings
|
||||
settings = BillingSettings(
|
||||
mandateId=mandateId,
|
||||
billingModel=BillingModelEnum.PREPAY_USER,
|
||||
defaultUserCredit=10.0,
|
||||
billingModel=BillingModelEnum.PREPAY_MANDATE,
|
||||
defaultUserCredit=0.0,
|
||||
warningThresholdPercent=10.0,
|
||||
blockOnZeroBalance=True,
|
||||
notifyOnWarning=True
|
||||
notifyOnWarning=True,
|
||||
)
|
||||
self.createSettings(settings)
|
||||
existingMandateIds.add(mandateId) # Track newly created
|
||||
|
|
@ -421,8 +476,8 @@ class BillingObjects:
|
|||
Ensure all users across all mandates have billing accounts.
|
||||
User accounts are always created regardless of billing model (for audit trail).
|
||||
Initial balance depends on billing model:
|
||||
- PREPAY_USER: defaultUserCredit from settings
|
||||
- PREPAY_MANDATE / CREDIT_POSTPAY: 0.0 (budget is on pool)
|
||||
- PREPAY_USER: defaultUserCredit from settings only for the root mandate; other mandates get 0.0
|
||||
- PREPAY_MANDATE: 0.0 (budget is on pool)
|
||||
|
||||
Uses bulk queries to minimize database connections.
|
||||
|
||||
|
|
@ -431,16 +486,23 @@ class BillingObjects:
|
|||
"""
|
||||
try:
|
||||
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)
|
||||
billingMandates = {} # mandateId -> (billingModel, defaultCredit)
|
||||
for s in allSettings:
|
||||
billingModel = s.get("billingModel", BillingModelEnum.UNLIMITED.value)
|
||||
if billingModel == BillingModelEnum.UNLIMITED.value:
|
||||
continue
|
||||
defaultCredit = s.get("defaultUserCredit", 10.0) if billingModel == BillingModelEnum.PREPAY_USER.value else 0.0
|
||||
billingMandates[s.get("mandateId")] = (billingModel, defaultCredit)
|
||||
billingModel = parseBillingModelFromStoredValue(s.get("billingModel")).value
|
||||
mid = s.get("mandateId")
|
||||
isRoot = rootMandateId is not None and str(mid) == str(rootMandateId)
|
||||
if billingModel == BillingModelEnum.PREPAY_USER.value:
|
||||
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:
|
||||
logger.debug("No billable mandates found, skipping account check")
|
||||
|
|
@ -457,13 +519,6 @@ class BillingObjects:
|
|||
existingAccountKeys.add(key)
|
||||
|
||||
# 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(
|
||||
UserMandate,
|
||||
recordFilter={"enabled": True}
|
||||
|
|
@ -711,68 +766,43 @@ class BillingObjects:
|
|||
"""
|
||||
Check if there's sufficient balance for an operation.
|
||||
|
||||
Budget logic:
|
||||
- PREPAY_USER: check user's own account balance
|
||||
- PREPAY_MANDATE: check mandate pool balance (shared by all users)
|
||||
- CREDIT_POSTPAY: check mandate pool credit limit
|
||||
- UNLIMITED: always allowed
|
||||
- PREPAY_USER: user.balance >= estimatedCost
|
||||
- PREPAY_MANDATE: mandate pool balance >= estimatedCost
|
||||
|
||||
User accounts are always ensured to exist (for audit trail).
|
||||
|
||||
Args:
|
||||
mandateId: Mandate ID
|
||||
userId: User ID
|
||||
estimatedCost: Estimated cost of the operation
|
||||
|
||||
Returns:
|
||||
BillingCheckResult
|
||||
Root mandate + PREPAY_USER: initial credit from settings.defaultUserCredit on first create.
|
||||
Missing settings: treated as PREPAY_MANDATE with empty pool (strict).
|
||||
"""
|
||||
settings = self.getSettings(mandateId)
|
||||
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))
|
||||
|
||||
if billingModel == BillingModelEnum.UNLIMITED:
|
||||
return BillingCheckResult(allowed=True, billingModel=billingModel)
|
||||
|
||||
# Always ensure user account exists (for audit trail)
|
||||
defaultCredit = settings.get("defaultUserCredit", 10.0)
|
||||
initialBalance = defaultCredit if billingModel == BillingModelEnum.PREPAY_USER else 0.0
|
||||
rootMandateId = _getCachedRootMandateId()
|
||||
isRootMandate = rootMandateId is not None and str(mandateId) == str(rootMandateId)
|
||||
if billingModel == BillingModelEnum.PREPAY_USER:
|
||||
initialBalance = defaultCredit if isRootMandate else 0.0
|
||||
else:
|
||||
initialBalance = 0.0
|
||||
self.getOrCreateUserAccount(mandateId, userId, initialBalance=initialBalance)
|
||||
|
||||
# Determine which balance to check based on billing model
|
||||
if billingModel == BillingModelEnum.PREPAY_USER:
|
||||
account = self.getUserAccount(mandateId, userId)
|
||||
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:
|
||||
return BillingCheckResult(allowed=True, billingModel=billingModel)
|
||||
poolAccount = self.getOrCreateMandateAccount(mandateId)
|
||||
currentBalance = poolAccount.get("balance", 0.0)
|
||||
|
||||
# PREPAY models - check balance
|
||||
if currentBalance < estimatedCost:
|
||||
if settings.get("blockOnZeroBalance", True):
|
||||
return BillingCheckResult(
|
||||
allowed=False,
|
||||
reason="INSUFFICIENT_BALANCE",
|
||||
currentBalance=currentBalance,
|
||||
requiredAmount=estimatedCost,
|
||||
billingModel=billingModel
|
||||
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:
|
||||
- PREPAY_USER: deduct from user's own balance
|
||||
- PREPAY_MANDATE: deduct from mandate pool balance
|
||||
- CREDIT_POSTPAY: deduct from mandate pool balance
|
||||
"""
|
||||
if priceCHF <= 0:
|
||||
return None
|
||||
|
|
@ -810,10 +839,7 @@ class BillingObjects:
|
|||
logger.debug(f"No billing settings for mandate {mandateId}, skipping usage recording")
|
||||
return None
|
||||
|
||||
billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value))
|
||||
|
||||
if billingModel == BillingModelEnum.UNLIMITED:
|
||||
return None
|
||||
billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
|
||||
|
||||
# Transaction is ALWAYS on the user's account (audit trail)
|
||||
userAccount = self.getOrCreateUserAccount(mandateId, userId)
|
||||
|
|
@ -838,12 +864,11 @@ class BillingObjects:
|
|||
|
||||
# Determine where to deduct balance
|
||||
if billingModel == BillingModelEnum.PREPAY_USER:
|
||||
# Deduct from user's own balance
|
||||
return self.createTransaction(transaction)
|
||||
else:
|
||||
# PREPAY_MANDATE / CREDIT_POSTPAY: deduct from mandate pool
|
||||
if billingModel == BillingModelEnum.PREPAY_MANDATE:
|
||||
poolAccount = self.getOrCreateMandateAccount(mandateId)
|
||||
return self.createTransaction(transaction, balanceAccountId=poolAccount["id"])
|
||||
return None
|
||||
|
||||
# =========================================================================
|
||||
# Workflow Cost Query
|
||||
|
|
@ -865,18 +890,10 @@ class BillingObjects:
|
|||
|
||||
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.
|
||||
USER -> MANDATE: all user balances are consolidated into the pool, user balances set to 0.
|
||||
|
||||
Args:
|
||||
mandateId: Mandate ID
|
||||
oldModel: Current billing model
|
||||
newModel: New billing model
|
||||
|
||||
Returns:
|
||||
Migration result dict with details
|
||||
PREPAY_MANDATE -> PREPAY_USER: pool debited, equal shares credited to user accounts.
|
||||
PREPAY_USER -> PREPAY_MANDATE: user wallets debited, pool credited with sum.
|
||||
"""
|
||||
result = {"oldModel": oldModel.value, "newModel": newModel.value, "migratedAmount": 0.0, "userCount": 0}
|
||||
|
||||
|
|
@ -884,47 +901,91 @@ class BillingObjects:
|
|||
return result
|
||||
|
||||
if oldModel == BillingModelEnum.PREPAY_MANDATE and newModel == BillingModelEnum.PREPAY_USER:
|
||||
# Pool -> distribute equally to users
|
||||
poolAccount = self.getMandateAccount(mandateId)
|
||||
if poolAccount and poolAccount.get("balance", 0.0) > 0:
|
||||
poolBalance = poolAccount["balance"]
|
||||
userAccounts = self.db.getRecordset(
|
||||
BillingAccount,
|
||||
recordFilter={"mandateId": mandateId, "accountType": AccountTypeEnum.USER.value}
|
||||
)
|
||||
if userAccounts:
|
||||
perUser = poolBalance / len(userAccounts)
|
||||
for acc in userAccounts:
|
||||
newBalance = acc.get("balance", 0.0) + perUser
|
||||
self.updateAccountBalance(acc["id"], newBalance)
|
||||
self.updateAccountBalance(poolAccount["id"], 0.0)
|
||||
poolBalance = poolAccount.get("balance", 0.0) if poolAccount else 0.0
|
||||
n = len(userAccounts)
|
||||
if poolAccount and poolBalance > 0:
|
||||
self.createTransaction(
|
||||
BillingTransaction(
|
||||
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["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")
|
||||
|
||||
elif oldModel == BillingModelEnum.PREPAY_USER and newModel == BillingModelEnum.PREPAY_MANDATE:
|
||||
# Users -> consolidate into pool
|
||||
if oldModel == BillingModelEnum.PREPAY_USER and newModel == BillingModelEnum.PREPAY_MANDATE:
|
||||
userAccounts = self.db.getRecordset(
|
||||
BillingAccount,
|
||||
recordFilter={"mandateId": mandateId, "accountType": AccountTypeEnum.USER.value}
|
||||
)
|
||||
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:
|
||||
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["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")
|
||||
|
||||
elif newModel == BillingModelEnum.PREPAY_MANDATE or newModel == BillingModelEnum.CREDIT_POSTPAY:
|
||||
# Any -> MANDATE/CREDIT: ensure pool account exists
|
||||
if newModel == BillingModelEnum.PREPAY_MANDATE:
|
||||
self.getOrCreateMandateAccount(mandateId, initialBalance=0.0)
|
||||
|
||||
return result
|
||||
|
|
@ -1027,8 +1088,6 @@ class BillingObjects:
|
|||
Shows the effective available budget:
|
||||
- PREPAY_USER: user's own account balance
|
||||
- PREPAY_MANDATE: mandate pool balance (shared budget visible to user)
|
||||
- CREDIT_POSTPAY: mandate pool balance
|
||||
|
||||
Args:
|
||||
userId: User ID
|
||||
|
||||
|
|
@ -1060,25 +1119,20 @@ class BillingObjects:
|
|||
if not settings:
|
||||
continue
|
||||
|
||||
billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value))
|
||||
if billingModel == BillingModelEnum.UNLIMITED:
|
||||
continue
|
||||
billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
|
||||
|
||||
# Determine effective balance based on billing model
|
||||
if billingModel == BillingModelEnum.PREPAY_USER:
|
||||
account = self.getOrCreateUserAccount(mandateId, userId)
|
||||
if not account:
|
||||
continue
|
||||
balance = account.get("balance", 0.0)
|
||||
warningThreshold = account.get("warningThreshold", 0.0)
|
||||
creditLimit = account.get("creditLimit")
|
||||
elif billingModel in [BillingModelEnum.PREPAY_MANDATE, BillingModelEnum.CREDIT_POSTPAY]:
|
||||
elif billingModel == BillingModelEnum.PREPAY_MANDATE:
|
||||
poolAccount = self.getOrCreateMandateAccount(mandateId)
|
||||
if not poolAccount:
|
||||
continue
|
||||
balance = poolAccount.get("balance", 0.0)
|
||||
warningThreshold = poolAccount.get("warningThreshold", 0.0)
|
||||
creditLimit = poolAccount.get("creditLimit")
|
||||
else:
|
||||
continue
|
||||
|
||||
|
|
@ -1089,7 +1143,6 @@ class BillingObjects:
|
|||
balance=balance,
|
||||
warningThreshold=warningThreshold,
|
||||
isWarning=balance <= warningThreshold,
|
||||
creditLimit=creditLimit
|
||||
))
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting balances for user: {e}")
|
||||
|
|
@ -1183,7 +1236,7 @@ class BillingObjects:
|
|||
if not mandateId:
|
||||
continue
|
||||
|
||||
billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value))
|
||||
billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
|
||||
|
||||
# Get mandate info
|
||||
mandate = appInterface.getMandate(mandateId)
|
||||
|
|
@ -1198,12 +1251,9 @@ class BillingObjects:
|
|||
)
|
||||
userCount = len(userAccounts)
|
||||
|
||||
# Total balance depends on billing model
|
||||
if billingModel == BillingModelEnum.PREPAY_USER:
|
||||
# Budget is distributed across user accounts
|
||||
totalBalance = sum(acc.get("balance", 0.0) for acc in userAccounts)
|
||||
elif billingModel in [BillingModelEnum.PREPAY_MANDATE, BillingModelEnum.CREDIT_POSTPAY]:
|
||||
# Budget is in the mandate pool
|
||||
elif billingModel == BillingModelEnum.PREPAY_MANDATE:
|
||||
poolAccount = self.getMandateAccount(mandateId)
|
||||
totalBalance = poolAccount.get("balance", 0.0) if poolAccount else 0.0
|
||||
else:
|
||||
|
|
@ -1215,9 +1265,8 @@ class BillingObjects:
|
|||
"billingModel": billingModel.value,
|
||||
"totalBalance": totalBalance,
|
||||
"userCount": userCount,
|
||||
"defaultUserCredit": settings.get("defaultUserCredit", 0.0),
|
||||
"defaultUserCredit": float(settings.get("defaultUserCredit", 0.0) or 0.0),
|
||||
"warningThresholdPercent": settings.get("warningThresholdPercent", 10.0),
|
||||
"blockOnZeroBalance": settings.get("blockOnZeroBalance", True)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ Data Namespace Structure:
|
|||
|
||||
GROUP-Berechtigung:
|
||||
- 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
|
||||
"""
|
||||
|
||||
|
|
@ -344,6 +344,20 @@ def buildRbacWhereClause(
|
|||
|
||||
# All records within the feature instance - only featureInstanceId filtering
|
||||
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:
|
||||
return {"condition": " AND ".join(baseConditions), "values": baseValues}
|
||||
return None
|
||||
|
|
@ -379,6 +393,14 @@ def buildRbacWhereClause(
|
|||
# But still apply featureInstanceId filter if provided
|
||||
if namespace in USER_OWNED_NAMESPACES:
|
||||
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 None
|
||||
|
||||
|
|
|
|||
|
|
@ -22,9 +22,18 @@ from modules.datamodels.datamodelFeatures import Feature, FeatureInstance
|
|||
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||
from modules.interfaces.interfaceFeatures import getFeatureInterface
|
||||
from modules.security.rbacCatalog import getCatalogService
|
||||
from modules.routes.routeNotifications import create_access_change_notification
|
||||
|
||||
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(
|
||||
prefix="/api/features",
|
||||
tags=["Features"],
|
||||
|
|
@ -1025,6 +1034,15 @@ def add_user_to_feature_instance(
|
|||
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 {
|
||||
"featureAccessId": featureAccessId,
|
||||
"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}"
|
||||
)
|
||||
|
||||
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 {
|
||||
"message": "User access removed",
|
||||
"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}"
|
||||
)
|
||||
|
||||
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 {
|
||||
"featureAccessId": featureAccessId,
|
||||
"userId": userId,
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ from modules.datamodels.datamodelBilling import (
|
|||
BillingAccount,
|
||||
BillingTransaction,
|
||||
BillingSettings,
|
||||
BillingAddress,
|
||||
BillingModelEnum,
|
||||
TransactionTypeEnum,
|
||||
ReferenceTypeEnum,
|
||||
|
|
@ -37,6 +36,7 @@ from modules.datamodels.datamodelBilling import (
|
|||
BillingStatisticsResponse,
|
||||
BillingStatisticsChartData,
|
||||
BillingCheckResult,
|
||||
parseBillingModelFromStoredValue,
|
||||
)
|
||||
|
||||
# Configure logger
|
||||
|
|
@ -263,10 +263,8 @@ class BillingSettingsUpdate(BaseModel):
|
|||
billingModel: Optional[BillingModelEnum] = None
|
||||
defaultUserCredit: Optional[float] = Field(None, ge=0)
|
||||
warningThresholdPercent: Optional[float] = Field(None, ge=0, le=100)
|
||||
blockOnZeroBalance: Optional[bool] = None
|
||||
notifyOnWarning: Optional[bool] = None
|
||||
notifyEmails: Optional[List[str]] = None
|
||||
billingAddress: Optional[BillingAddress] = None
|
||||
|
||||
|
||||
class TransactionResponse(BaseModel):
|
||||
|
|
@ -295,7 +293,6 @@ class AccountSummary(BaseModel):
|
|||
userId: Optional[str]
|
||||
accountType: str
|
||||
balance: float
|
||||
creditLimit: Optional[float]
|
||||
warningThreshold: float
|
||||
enabled: bool
|
||||
|
||||
|
|
@ -323,7 +320,6 @@ class MandateBalanceResponse(BaseModel):
|
|||
userCount: int
|
||||
defaultUserCredit: float
|
||||
warningThresholdPercent: float
|
||||
blockOnZeroBalance: bool
|
||||
|
||||
|
||||
class UserBalanceResponse(BaseModel):
|
||||
|
|
@ -427,12 +423,12 @@ def _creditStripeSessionIfNeeded(
|
|||
if not settings:
|
||||
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 not user_id:
|
||||
raise HTTPException(status_code=400, detail="userId required for PREPAY_USER")
|
||||
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)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=f"Cannot add credit to {billing_model.value}")
|
||||
|
|
@ -529,11 +525,10 @@ def getBalanceForMandate(
|
|||
return BillingBalanceResponse(
|
||||
mandateId=targetMandateId,
|
||||
mandateName=mandateName,
|
||||
billingModel=checkResult.billingModel or BillingModelEnum.UNLIMITED,
|
||||
billingModel=checkResult.billingModel or BillingModelEnum.PREPAY_MANDATE,
|
||||
balance=checkResult.currentBalance or 0.0,
|
||||
warningThreshold=0.0, # TODO: Get from account
|
||||
isWarning=False,
|
||||
creditLimit=None
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -622,7 +617,7 @@ def getStatistics(
|
|||
costByFeature={}
|
||||
)
|
||||
|
||||
billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value))
|
||||
billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
|
||||
|
||||
# Transactions are always on user accounts (audit trail)
|
||||
account = billingInterface.getUserAccount(ctx.mandateId, ctx.user.id)
|
||||
|
|
@ -750,8 +745,12 @@ def createOrUpdateSettings(
|
|||
if updates:
|
||||
# Check if billing model is changing - trigger budget migration
|
||||
if "billingModel" in updates:
|
||||
oldModel = BillingModelEnum(existingSettings.get("billingModel", BillingModelEnum.UNLIMITED.value))
|
||||
newModel = BillingModelEnum(updates["billingModel"]) if isinstance(updates["billingModel"], str) else updates["billingModel"]
|
||||
oldModel = parseBillingModelFromStoredValue(existingSettings.get("billingModel"))
|
||||
newModel = (
|
||||
BillingModelEnum(updates["billingModel"])
|
||||
if isinstance(updates["billingModel"], str)
|
||||
else updates["billingModel"]
|
||||
)
|
||||
if oldModel != newModel:
|
||||
migrationResult = billingInterface.switchBillingModel(targetMandateId, oldModel, newModel)
|
||||
logger.info(f"Billing model migration for {targetMandateId}: {migrationResult}")
|
||||
|
|
@ -764,13 +763,27 @@ def createOrUpdateSettings(
|
|||
|
||||
newSettings = BillingSettings(
|
||||
mandateId=targetMandateId,
|
||||
billingModel=settingsUpdate.billingModel or BillingModelEnum.UNLIMITED,
|
||||
defaultUserCredit=settingsUpdate.defaultUserCredit or 10.0,
|
||||
warningThresholdPercent=settingsUpdate.warningThresholdPercent or 10.0,
|
||||
blockOnZeroBalance=settingsUpdate.blockOnZeroBalance if settingsUpdate.blockOnZeroBalance is not None else True,
|
||||
notifyOnWarning=settingsUpdate.notifyOnWarning if settingsUpdate.notifyOnWarning is not None else True,
|
||||
billingModel=(
|
||||
settingsUpdate.billingModel
|
||||
if settingsUpdate.billingModel is not None
|
||||
else BillingModelEnum.PREPAY_MANDATE
|
||||
),
|
||||
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 [],
|
||||
billingAddress=settingsUpdate.billingAddress
|
||||
)
|
||||
|
||||
return billingInterface.createSettings(newSettings)
|
||||
|
|
@ -803,7 +816,7 @@ def addCredit(
|
|||
if not settings:
|
||||
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
|
||||
if billingModel == BillingModelEnum.PREPAY_USER:
|
||||
|
|
@ -816,7 +829,7 @@ def addCredit(
|
|||
creditRequest.userId,
|
||||
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
|
||||
account = billingInterface.getOrCreateMandateAccount(targetMandateId, initialBalance=0.0)
|
||||
else:
|
||||
|
|
@ -866,7 +879,7 @@ def createCheckoutSession(
|
|||
if not settings:
|
||||
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 not checkoutRequest.userId:
|
||||
|
|
@ -875,7 +888,7 @@ def createCheckoutSession(
|
|||
raise HTTPException(status_code=403, detail="Users can only load credit to their own account")
|
||||
if not _isMemberOfMandate(ctx, targetMandateId):
|
||||
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):
|
||||
raise HTTPException(status_code=403, detail="Mandate admin role required to load mandate credit")
|
||||
else:
|
||||
|
|
@ -933,7 +946,7 @@ def confirmCheckoutSession(
|
|||
if not settings:
|
||||
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 not user_id:
|
||||
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")
|
||||
if not _isMemberOfMandate(ctx, mandate_id):
|
||||
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):
|
||||
raise HTTPException(status_code=403, detail="Mandate admin role required")
|
||||
else:
|
||||
|
|
@ -1041,7 +1054,6 @@ def getAccounts(
|
|||
userId=acc.get("userId"),
|
||||
accountType=acc.get("accountType"),
|
||||
balance=acc.get("balance", 0.0),
|
||||
creditLimit=acc.get("creditLimit"),
|
||||
warningThreshold=acc.get("warningThreshold", 0.0),
|
||||
enabled=acc.get("enabled", True)
|
||||
))
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from fastapi import status
|
|||
import logging
|
||||
import json
|
||||
import math
|
||||
from urllib.parse import quote
|
||||
|
||||
from modules.datamodels.datamodelUam import User, UserConnection, AuthAuthority, ConnectionStatus
|
||||
from modules.datamodels.datamodelSecurity import Token
|
||||
|
|
@ -445,24 +446,12 @@ def connect_service(
|
|||
detail="Connection not found"
|
||||
)
|
||||
|
||||
# Initiate OAuth flow with state=connect
|
||||
# Data-app OAuth (JWT state issued server-side in /auth/connect)
|
||||
auth_url = None
|
||||
if connection.authority == AuthAuthority.MSFT:
|
||||
# Use the same login endpoint with state=connect to ensure account selection
|
||||
# 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)}"
|
||||
auth_url = f"/api/msft/auth/connect?connectionId={quote(connectionId, safe='')}"
|
||||
elif connection.authority == AuthAuthority.GOOGLE:
|
||||
state_data = {
|
||||
"type": "connect",
|
||||
"connectionId": connectionId,
|
||||
"userId": currentUser.id # Add current user ID
|
||||
}
|
||||
auth_url = f"/api/google/login?state={json.dumps(state_data)}"
|
||||
auth_url = f"/api/google/auth/connect?connectionId={quote(connectionId, safe='')}"
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ from modules.auth import limiter, requireSysAdminRole, getRequestContext, Reques
|
|||
|
||||
# Import interfaces
|
||||
import modules.interfaces.interfaceDbApp as interfaceDbApp
|
||||
from modules.interfaces.interfaceDbBilling import _getRootInterface as _getBillingRootInterface
|
||||
from modules.shared.attributeUtils import getModelAttributeDefinitions
|
||||
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.datamodelRbac import Role
|
||||
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"
|
||||
)
|
||||
|
||||
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}")
|
||||
|
||||
return newMandate
|
||||
|
|
@ -613,6 +624,15 @@ def add_user_to_mandate(
|
|||
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(
|
||||
id=str(userMandate.id), # UserMandate ID as primary key
|
||||
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}")
|
||||
|
||||
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}
|
||||
|
||||
except HTTPException:
|
||||
|
|
@ -792,6 +821,16 @@ def update_user_roles_in_mandate(
|
|||
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(
|
||||
id=str(membership.id), # UserMandate ID as primary key
|
||||
userId=targetUserId,
|
||||
|
|
@ -814,6 +853,28 @@ def update_user_roles_in_mandate(
|
|||
# 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]:
|
||||
"""
|
||||
Get list of mandate IDs where the user has the admin role.
|
||||
|
|
|
|||
|
|
@ -89,6 +89,31 @@ def _createNotification(
|
|||
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(
|
||||
userId: str,
|
||||
invitationId: str,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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.interfaces.interfaceDbApp import getInterface, getRootInterface
|
||||
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.timeUtils import getUtcTimestamp
|
||||
|
||||
|
|
@ -164,6 +164,7 @@ def login(
|
|||
id=jti,
|
||||
userId=user.id,
|
||||
authority=AuthAuthority.LOCAL,
|
||||
tokenPurpose=TokenPurpose.AUTH_SESSION,
|
||||
tokenAccess=access_token,
|
||||
tokenType="bearer",
|
||||
expiresAt=expires_at.timestamp(),
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -110,15 +110,38 @@ def _getUserFeatureAccess(db, userId: str, instanceId: str) -> Dict[str, Any] |
|
|||
return accesses[0] if accesses else None
|
||||
|
||||
|
||||
def _findUserRole(rootInterface, instanceId: str, featureCode: str) -> str | None:
|
||||
"""Find the user-level role for a feature instance."""
|
||||
def _findStoreUserRoleId(
|
||||
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)
|
||||
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:
|
||||
if role.roleLabel == userRoleLabel:
|
||||
return str(role.id)
|
||||
for role in instanceRoles:
|
||||
if "user" in role.roleLabel.lower() and "admin" not in role.roleLabel.lower():
|
||||
low = (role.roleLabel or "").lower()
|
||||
if "admin" in low:
|
||||
continue
|
||||
if "user" in low:
|
||||
return str(role.id)
|
||||
return None
|
||||
|
||||
|
|
@ -249,8 +272,20 @@ def activateStoreFeature(
|
|||
createdAccess = db.recordCreate(FeatureAccess, featureAccess.model_dump())
|
||||
featureAccessId = createdAccess.get("id")
|
||||
|
||||
userRoleId = _findUserRole(rootInterface, instanceId, featureCode)
|
||||
if userRoleId:
|
||||
userRoleId = _findStoreUserRoleId(rootInterface, catalogService, instanceId, featureCode)
|
||||
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(
|
||||
featureAccessId=featureAccessId,
|
||||
roleId=userRoleId
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ Core service - not requested by features directly.
|
|||
import logging
|
||||
from typing import Optional, Callable, Any
|
||||
|
||||
from modules.datamodels.datamodelSecurity import Token
|
||||
from modules.datamodels.datamodelSecurity import Token, TokenPurpose
|
||||
from modules.auth import TokenManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -34,6 +34,16 @@ class SecurityService:
|
|||
token = self._interfaceDbApp.getConnectionToken(connectionId)
|
||||
if not token:
|
||||
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(
|
||||
token,
|
||||
secondsBeforeExpiry=secondsBeforeExpiry,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ from modules.serviceCenter.services.serviceAgent.conversationManager import (
|
|||
)
|
||||
from modules.shared.timeUtils import getUtcTimestamp
|
||||
from modules.shared.jsonUtils import closeJsonStructures
|
||||
from modules.serviceCenter.services.serviceBilling.mainServiceBilling import (
|
||||
InsufficientBalanceException,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -175,6 +178,18 @@ async def runAgentLoop(
|
|||
else:
|
||||
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:
|
||||
logger.error(f"AI call failed in round {state.currentRound}: {e}", exc_info=True)
|
||||
state.status = AgentStatusEnum.ERROR
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@ from modules.shared.jsonUtils import (
|
|||
)
|
||||
from .subJsonResponseHandling import JsonResponseHandler
|
||||
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 (
|
||||
getService as getBillingService,
|
||||
InsufficientBalanceException,
|
||||
|
|
@ -592,10 +596,19 @@ detectedIntent-Werte:
|
|||
f"Balance {balance_str} CHF, "
|
||||
f"Reason: {balanceCheck.reason}"
|
||||
)
|
||||
raise InsufficientBalanceException(
|
||||
currentBalance=balanceCheck.currentBalance or 0.0,
|
||||
requiredAmount=estimatedCost,
|
||||
message=f"Ungenugendes Guthaben. Aktuell: CHF {balance_str}"
|
||||
if balanceCheck.billingModel == BillingModelEnum.PREPAY_MANDATE:
|
||||
ulabel = (getattr(user, "email", None) or getattr(user, "username", None) or str(user.id))
|
||||
maybeEmailMandatePoolExhausted(
|
||||
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}"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -22,6 +22,7 @@ from modules.datamodels.datamodelBilling import (
|
|||
ReferenceTypeEnum,
|
||||
BillingTransaction,
|
||||
BillingBalanceResponse,
|
||||
parseBillingModelFromStoredValue,
|
||||
)
|
||||
from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface
|
||||
|
||||
|
|
@ -333,7 +334,7 @@ class BillingService:
|
|||
logger.warning(f"No billing settings for mandate {self.mandateId}")
|
||||
return None
|
||||
|
||||
billingModel = BillingModelEnum(settings.get("billingModel", BillingModelEnum.UNLIMITED.value))
|
||||
billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
|
||||
|
||||
# Get or create account
|
||||
if billingModel == BillingModelEnum.PREPAY_USER:
|
||||
|
|
@ -389,15 +390,127 @@ class BillingService:
|
|||
# Exception Classes
|
||||
# ============================================================================
|
||||
|
||||
class InsufficientBalanceException(Exception):
|
||||
"""Raised when there's insufficient balance for an operation."""
|
||||
BILLING_USER_ACTION_TOP_UP_SELF = "TOP_UP_SELF"
|
||||
BILLING_USER_ACTION_CONTACT_MANDATE_ADMIN = "CONTACT_MANDATE_ADMIN"
|
||||
|
||||
def __init__(self, currentBalance: float, requiredAmount: float, message: str = None):
|
||||
self.currentBalance = currentBalance
|
||||
self.requiredAmount = requiredAmount
|
||||
self.message = message or f"Insufficient balance. Current: {currentBalance:.2f} CHF, Required: {requiredAmount:.2f} CHF"
|
||||
|
||||
def _userActionForBillingModel(bm: BillingModelEnum) -> str:
|
||||
if bm == BillingModelEnum.PREPAY_USER:
|
||||
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)
|
||||
|
||||
@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):
|
||||
"""Raised when a user doesn't have permission to use an AI provider."""
|
||||
|
|
|
|||
|
|
@ -447,6 +447,11 @@ RESOURCE_OBJECTS = [
|
|||
"label": {"en": "Store: AI Workspace", "de": "Store: AI Workspace", "fr": "Store: AI 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",
|
||||
"label": {"en": "Authentication API", "de": "Authentifizierungs-API", "fr": "API d'authentification"},
|
||||
|
|
|
|||
Loading…
Reference in a new issue