From e1557e9cc9c424cdf5ee0a7bc333b152aa6811ac Mon Sep 17 00:00:00 2001
From: patrick-motsch
Date: Sat, 21 Feb 2026 00:56:53 +0100
Subject: [PATCH] trustee connections
---
.../trustee/accounting/accountingBridge.py | 14 ++-
.../connectors/accountingConnectorRma.py | 23 +++--
modules/features/trustee/mainTrustee.py | 11 ++-
.../features/trustee/routeFeatureTrustee.py | 93 ++++++++++++++++---
modules/routes/routeAdminFeatures.py | 5 +
modules/routes/routeSecurityLocal.py | 4 +-
.../mainServiceSharepoint.py | 6 ++
.../actions/getExpensesFromPdf.py | 20 +++-
...cript_db_migrate_accessrules_objectkeys.py | 1 +
9 files changed, 151 insertions(+), 26 deletions(-)
diff --git a/modules/features/trustee/accounting/accountingBridge.py b/modules/features/trustee/accounting/accountingBridge.py
index ce0b40fa..664166c0 100644
--- a/modules/features/trustee/accounting/accountingBridge.py
+++ b/modules/features/trustee/accounting/accountingBridge.py
@@ -44,8 +44,14 @@ class AccountingBridge:
from modules.shared.configuration import decryptValue
import json
try:
+ if not encryptedConfig:
+ logger.error("Accounting config encryptedConfig is empty")
+ return {}
decrypted = decryptValue(encryptedConfig, keyName="accountingConfig")
- return json.loads(decrypted) if isinstance(decrypted, str) else decrypted
+ result = json.loads(decrypted) if isinstance(decrypted, str) else decrypted
+ configKeys = list(result.keys()) if isinstance(result, dict) else []
+ logger.info(f"Decrypted accounting config successfully, keys: {configKeys}")
+ return result
except Exception as e:
logger.error(f"Failed to decrypt accounting config: {e}")
return {}
@@ -54,15 +60,19 @@ class AccountingBridge:
"""Load config, decrypt, resolve connector. Returns (connector, plainConfig, accountingConfigRecord)."""
configRecord = await self.getActiveConfig(featureInstanceId)
if not configRecord:
+ logger.warning(f"No active accounting config for instance {featureInstanceId}")
return None, None, None
connectorType = configRecord.get("connectorType")
+ logger.info(f"Resolving connector '{connectorType}' for instance {featureInstanceId}")
connector = self._registry.getConnector(connectorType)
if not connector:
- logger.error(f"Accounting connector '{connectorType}' not found")
+ logger.error(f"Accounting connector '{connectorType}' not found in registry")
return None, None, configRecord
plainConfig = self._decryptConfig(configRecord.get("encryptedConfig", ""))
+ if not plainConfig:
+ logger.error(f"Decrypted config is empty for connector '{connectorType}'")
return connector, plainConfig, configRecord
def _buildBookingFromPosition(self, position: Dict[str, Any]) -> AccountingBooking:
diff --git a/modules/features/trustee/accounting/connectors/accountingConnectorRma.py b/modules/features/trustee/accounting/connectors/accountingConnectorRma.py
index b47e9849..30aeff39 100644
--- a/modules/features/trustee/accounting/connectors/accountingConnectorRma.py
+++ b/modules/features/trustee/accounting/connectors/accountingConnectorRma.py
@@ -51,25 +51,35 @@ class AccountingConnectorRma(BaseAccountingConnector):
]
def _buildUrl(self, config: Dict[str, Any], resource: str) -> str:
- clientName = config["clientName"]
+ clientName = config.get("clientName", "")
return f"{_BASE_URL}/{clientName}/{resource}"
def _buildHeaders(self, config: Dict[str, Any]) -> Dict[str, str]:
+ """PAT must not be in query params; RMA expects Authorization header."""
+ apiKey = config.get("apiKey", "")
return {
- "X-RMA-KEY": config["apiKey"],
+ "Authorization": f"Bearer {apiKey}",
"Accept": "application/json",
"Content-Type": "application/json",
}
async def testConnection(self, config: Dict[str, Any]) -> SyncResult:
+ clientName = config.get("clientName", "")
+ apiKey = config.get("apiKey", "")
+
+ if not clientName or not apiKey:
+ return SyncResult(success=False, errorMessage=f"Missing credentials: clientName={bool(clientName)}, apiKey={bool(apiKey)}")
+
+ url = self._buildUrl(config, "customers")
+ headers = self._buildHeaders(config)
try:
async with aiohttp.ClientSession() as session:
- url = self._buildUrl(config, "customers")
- async with session.get(url, headers=self._buildHeaders(config), timeout=aiohttp.ClientTimeout(total=15)) as resp:
+ async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=15)) as resp:
if resp.status == 200:
+ logger.info("RMA connection successful")
return SyncResult(success=True)
body = await resp.text()
- return SyncResult(success=False, errorMessage=f"HTTP {resp.status}: {body[:200]}")
+ return SyncResult(success=False, errorMessage=f"HTTP {resp.status}: {body[:300]}")
except Exception as e:
return SyncResult(success=False, errorMessage=str(e))
@@ -181,7 +191,8 @@ class AccountingConnectorRma(BaseAccountingConnector):
formData = aiohttp.FormData()
formData.add_field("Filedata", fileContent, filename=fileName, content_type=mimeType)
- headers = {"X-RMA-KEY": config["apiKey"], "Accept": "application/json"}
+ headers = self._buildHeaders(config)
+ headers.pop("Content-Type", None) # let aiohttp set multipart boundary
async with aiohttp.ClientSession() as session:
url = self._buildUrl(config, "belege")
async with session.post(url, headers=headers, data=formData, timeout=aiohttp.ClientTimeout(total=60)) as resp:
diff --git a/modules/features/trustee/mainTrustee.py b/modules/features/trustee/mainTrustee.py
index 04c010a0..10d0a95b 100644
--- a/modules/features/trustee/mainTrustee.py
+++ b/modules/features/trustee/mainTrustee.py
@@ -38,6 +38,11 @@ UI_OBJECTS = [
"label": {"en": "Expense Import", "de": "Spesen Import", "fr": "Import de dépenses"},
"meta": {"area": "expense-import"}
},
+ {
+ "objectKey": "ui.feature.trustee.settings",
+ "label": {"en": "Accounting Settings", "de": "Buchhaltungs-Einstellungen", "fr": "Paramètres comptables"},
+ "meta": {"area": "settings", "admin_only": True}
+ },
{
"objectKey": "ui.feature.trustee.instance-roles",
"label": {"en": "Instance Roles & Permissions", "de": "Instanz-Rollen & Berechtigungen", "fr": "Rôles et permissions d'instance"},
@@ -61,7 +66,7 @@ DATA_OBJECTS = [
{
"objectKey": "data.feature.trustee.TrusteeAccountingConfig",
"label": {"en": "Accounting Config", "de": "Buchhaltungs-Konfiguration", "fr": "Config. comptable"},
- "meta": {"table": "TrusteeAccountingConfig", "fields": ["id", "connectorType", "displayLabel", "isActive"]}
+ "meta": {"table": "TrusteeAccountingConfig", "fields": ["id", "connectorType", "displayLabel", "encryptedConfig", "isActive"]}
},
{
"objectKey": "data.feature.trustee.TrusteeAccountingSync",
@@ -163,8 +168,12 @@ TEMPLATE_ROLES = [
{"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.trustee.positions", "view": True},
{"context": "UI", "item": "ui.feature.trustee.documents", "view": True},
+ {"context": "UI", "item": "ui.feature.trustee.settings", "view": True},
# Group-level DATA access
{"context": "DATA", "item": None, "view": True, "read": "g", "create": "g", "update": "g", "delete": "g"},
+ # Accounting sync permission
+ {"context": "RESOURCE", "item": "resource.feature.trustee.accounting.sync", "view": True},
+ {"context": "RESOURCE", "item": "resource.feature.trustee.accounting.view", "view": True},
]
},
{
diff --git a/modules/features/trustee/routeFeatureTrustee.py b/modules/features/trustee/routeFeatureTrustee.py
index 2ad4da7e..cab6eecb 100644
--- a/modules/features/trustee/routeFeatureTrustee.py
+++ b/modules/features/trustee/routeFeatureTrustee.py
@@ -13,6 +13,7 @@ from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Quer
from fastapi.responses import StreamingResponse
from typing import List, Dict, Any, Optional
from fastapi import status
+from pydantic import BaseModel, Field
import logging
import json
import io
@@ -1179,6 +1180,23 @@ def get_available_accounting_connectors(
return _getAccountingRegistry().getAvailableConnectors()
+# Placeholder returned for secret config fields so frontend can prefill form without sending real secrets.
+_CONFIG_PLACEHOLDER = "***"
+
+
+def _getConfigMasked(connectorType: str, plainConfig: Dict[str, Any]) -> Dict[str, str]:
+ """Build config with secret values replaced by placeholder for GET response."""
+ from .accounting.accountingRegistry import _getAccountingRegistry
+ connector = _getAccountingRegistry().getConnector(connectorType)
+ if not connector:
+ return {k: (v if isinstance(v, str) else str(v)) for k, v in (plainConfig or {}).items()}
+ secretKeys = {f.key for f in connector.getRequiredConfigFields() if f.secret}
+ return {
+ k: _CONFIG_PLACEHOLDER if k in secretKeys else (v if isinstance(v, str) else str(v) if v is not None else "")
+ for k, v in (plainConfig or {}).items()
+ }
+
+
@router.get("/{instanceId}/accounting/config")
@limiter.limit("30/minute")
def get_accounting_config(
@@ -1186,57 +1204,104 @@ def get_accounting_config(
instanceId: str = Path(..., description="Feature Instance ID"),
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
- """Get the active accounting config for this instance (credentials redacted)."""
+ """Get the active accounting config for this instance. Credentials are masked (secret fields = ***) for form prefill."""
mandateId = _validateInstanceAccess(instanceId, context)
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
from .datamodelFeatureTrustee import TrusteeAccountingConfig
+ from modules.shared.configuration import decryptValue
records = interface.db.getRecordset(TrusteeAccountingConfig, recordFilter={"featureInstanceId": instanceId, "isActive": True})
if not records:
return {"configured": False}
record = {k: v for k, v in records[0].items() if not k.startswith("_")}
- record.pop("encryptedConfig", None)
+ encryptedConfig = record.pop("encryptedConfig", None)
record["configured"] = True
+ if encryptedConfig:
+ try:
+ import json
+ plain = json.loads(decryptValue(encryptedConfig, keyName="accountingConfig"))
+ record["configMasked"] = _getConfigMasked(record.get("connectorType", ""), plain)
+ except Exception:
+ record["configMasked"] = {}
+ else:
+ record["configMasked"] = {}
return record
+class SaveAccountingConfigBody(BaseModel):
+ """Request body for saving accounting config. Ensures 'config' is present and used."""
+ connectorType: str = ""
+ displayLabel: str = ""
+ config: Dict[str, Any] = Field(default_factory=dict, description="Connector credentials (e.g. clientName, apiKey)")
+
+
@router.post("/{instanceId}/accounting/config", status_code=201)
@limiter.limit("5/minute")
def save_accounting_config(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
- data: Dict[str, Any] = Body(...),
+ body: SaveAccountingConfigBody = Body(...),
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""Save or update the accounting config for this instance.
- Body: { connectorType, displayLabel, config: { ... plain credentials ... } }
+ Body: { connectorType, displayLabel, config: { clientName, apiKey, ... } }
+ The 'config' object is stored encrypted; without it credentials would be empty in DB.
"""
mandateId = _validateInstanceAccess(instanceId, context)
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
from .datamodelFeatureTrustee import TrusteeAccountingConfig
from modules.shared.configuration import encryptValue
- import json, uuid as _uuid
+ import uuid as _uuid
- plainConfig = data.get("config", {})
- encryptedConfig = encryptValue(json.dumps(plainConfig), keyName="accountingConfig")
+ plainConfig = body.config if isinstance(body.config, dict) else {}
+ # When updating, empty config is normal (frontend never receives credentials from GET).
+ # Do not overwrite encryptedConfig with empty – keep existing credentials.
+ if not plainConfig and body.connectorType:
+ logger.warning("Accounting config save: config is empty (credentials will not be stored or updated)")
+ else:
+ logger.info(
+ "Accounting config save: instanceId=%s connectorType=%s configKeys=%s",
+ instanceId, body.connectorType, list(plainConfig.keys())
+ )
existing = interface.db.getRecordset(TrusteeAccountingConfig, recordFilter={"featureInstanceId": instanceId})
if existing:
configId = existing[0].get("id")
- interface.db.recordModify(TrusteeAccountingConfig, configId, {
- "connectorType": data.get("connectorType", ""),
- "displayLabel": data.get("displayLabel", ""),
- "encryptedConfig": encryptedConfig,
+ updatePayload = {
+ "connectorType": body.connectorType or "",
+ "displayLabel": body.displayLabel or "",
"isActive": True,
- })
+ }
+ if plainConfig:
+ # Merge with existing: placeholder or empty = keep existing value (so form prefill does not overwrite secrets).
+ from modules.shared.configuration import decryptValue
+ existingEnc = existing[0].get("encryptedConfig") or ""
+ merged = {}
+ if existingEnc:
+ try:
+ merged = json.loads(decryptValue(existingEnc, keyName="accountingConfig"))
+ except Exception:
+ pass
+ for k, v in plainConfig.items():
+ if v is not None and str(v).strip() and str(v).strip() != _CONFIG_PLACEHOLDER:
+ merged[k] = v
+ updatePayload["encryptedConfig"] = encryptValue(json.dumps(merged), keyName="accountingConfig")
+ interface.db.recordModify(TrusteeAccountingConfig, configId, updatePayload)
return {"message": "Accounting config updated", "id": configId}
+ if not plainConfig:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="config is required for new integration (e.g. clientName, apiKey)."
+ )
+ encryptedConfig = encryptValue(json.dumps(plainConfig), keyName="accountingConfig")
+
configRecord = {
"id": str(_uuid.uuid4()),
"featureInstanceId": instanceId,
- "connectorType": data.get("connectorType", ""),
- "displayLabel": data.get("displayLabel", ""),
+ "connectorType": body.connectorType or "",
+ "displayLabel": body.displayLabel or "",
"encryptedConfig": encryptedConfig,
"isActive": True,
"mandateId": mandateId,
diff --git a/modules/routes/routeAdminFeatures.py b/modules/routes/routeAdminFeatures.py
index 2219e75d..75cf076a 100644
--- a/modules/routes/routeAdminFeatures.py
+++ b/modules/routes/routeAdminFeatures.py
@@ -129,6 +129,7 @@ def get_my_feature_instances(
mandatesMap: Dict[str, Dict[str, Any]] = {}
featuresMap: Dict[str, Dict[str, Any]] = {} # key: mandateId_featureCode
+ catalogService = getCatalogService()
for access in featureAccesses:
if not access.enabled:
continue
@@ -137,6 +138,10 @@ def get_my_feature_instances(
if not instance or not instance.enabled:
continue
+ # Only show features that exist in this app's catalog (e.g. PowerOn vs Actan share DB)
+ if not catalogService.getFeatureDefinition(instance.featureCode):
+ continue
+
# Get mandate info
mandateId = str(instance.mandateId)
if mandateId not in mandatesMap:
diff --git a/modules/routes/routeSecurityLocal.py b/modules/routes/routeSecurityLocal.py
index dd04a67a..b846af63 100644
--- a/modules/routes/routeSecurityLocal.py
+++ b/modules/routes/routeSecurityLocal.py
@@ -174,8 +174,8 @@ def login(
# Save access token
userInterface.saveAccessToken(token)
- # Log successful login
- # MULTI-TENANT: Login is a system-level function, no mandate context
+ # Log successful login (app log file + audit DB for traceability)
+ logger.info("Login successful for username=%s (userId=%s)", formData.username, str(user.id))
try:
from modules.shared.auditLogger import audit_logger
audit_logger.logUserAccess(
diff --git a/modules/services/serviceSharepoint/mainServiceSharepoint.py b/modules/services/serviceSharepoint/mainServiceSharepoint.py
index 7ee89669..dc8717a5 100644
--- a/modules/services/serviceSharepoint/mainServiceSharepoint.py
+++ b/modules/services/serviceSharepoint/mainServiceSharepoint.py
@@ -5,10 +5,16 @@
import logging
import aiohttp
import asyncio
+import time
from typing import Dict, Any, List, Optional
logger = logging.getLogger(__name__)
+# Cache for discoverSites() to avoid hitting Graph API on every folder-options call (e.g. when UI loads site list).
+# Key: token prefix (per user), Value: (expiry_ts, sites). TTL 5 minutes.
+_discoverSitesCache: Dict[str, tuple] = {}
+_DISCOVER_SITES_TTL_SEC = 300
+
class SharepointService:
"""SharePoint connector using Microsoft Graph API for reliable authentication."""
diff --git a/modules/workflows/methods/methodSharepoint/actions/getExpensesFromPdf.py b/modules/workflows/methods/methodSharepoint/actions/getExpensesFromPdf.py
index cd9742bd..9c873bb2 100644
--- a/modules/workflows/methods/methodSharepoint/actions/getExpensesFromPdf.py
+++ b/modules/workflows/methods/methodSharepoint/actions/getExpensesFromPdf.py
@@ -633,7 +633,7 @@ def _saveToTrusteePosition(
except Exception as e:
logger.error(f"Error creating TrusteeDocument: {str(e)}")
- # Step 2: Save positions with direct documentId reference
+ # Step 2: Save positions with direct documentId reference and accounting fields
for record in records:
try:
position = {
@@ -649,6 +649,11 @@ def _saveToTrusteePosition(
"originalAmount": _parseFloat(record.get("originalAmount", 0)) or _parseFloat(record.get("bookingAmount", 0)),
"vatPercentage": _parseFloat(record.get("vatPercentage", 0)),
"vatAmount": _parseFloat(record.get("vatAmount", 0)),
+ "debitAccountNumber": record.get("debitAccountNumber") or None,
+ "creditAccountNumber": record.get("creditAccountNumber") or None,
+ "taxCode": record.get("taxCode") or None,
+ "costCenter": record.get("costCenter") or None,
+ "bookingReference": record.get("bookingReference") or None,
"featureInstanceId": featureInstanceId,
"mandateId": mandateId
}
@@ -662,6 +667,19 @@ def _saveToTrusteePosition(
except Exception as e:
logger.error(f"Failed to save position: {str(e)}")
+ # Step 3: Auto-sync to accounting system if configured
+ if savedCount > 0 and savedPositionIds:
+ try:
+ from modules.features.trustee.accounting.accountingBridge import AccountingBridge
+ bridge = AccountingBridge(trusteeInterface)
+ configRecord = await bridge.getActiveConfig(featureInstanceId)
+ if configRecord:
+ syncResults = await bridge.pushBatchToAccounting(featureInstanceId, savedPositionIds)
+ syncedCount = sum(1 for r in syncResults if r.success)
+ logger.info(f"Auto-synced {syncedCount}/{len(savedPositionIds)} positions to accounting system")
+ except Exception as e:
+ logger.warning(f"Accounting auto-sync skipped (non-critical): {e}")
+
return savedCount
diff --git a/scripts/script_db_migrate_accessrules_objectkeys.py b/scripts/script_db_migrate_accessrules_objectkeys.py
index d3054aa1..b0b5ce4a 100644
--- a/scripts/script_db_migrate_accessrules_objectkeys.py
+++ b/scripts/script_db_migrate_accessrules_objectkeys.py
@@ -35,6 +35,7 @@ MIGRATION_MAP: Dict[str, Dict[str, str]] = {
"dashboard": "ui.feature.trustee.dashboard",
"positions": "ui.feature.trustee.positions",
"documents": "ui.feature.trustee.documents",
+ "settings": "ui.feature.trustee.settings",
"instance-roles": "ui.feature.trustee.instance-roles",
# RESOURCE items
"instance-roles.manage": "resource.feature.trustee.instance-roles.manage",