trustee connections

This commit is contained in:
patrick-motsch 2026-02-21 00:56:53 +01:00
parent 899636424b
commit e1557e9cc9
9 changed files with 151 additions and 26 deletions

View file

@ -44,8 +44,14 @@ class AccountingBridge:
from modules.shared.configuration import decryptValue from modules.shared.configuration import decryptValue
import json import json
try: try:
if not encryptedConfig:
logger.error("Accounting config encryptedConfig is empty")
return {}
decrypted = decryptValue(encryptedConfig, keyName="accountingConfig") 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: except Exception as e:
logger.error(f"Failed to decrypt accounting config: {e}") logger.error(f"Failed to decrypt accounting config: {e}")
return {} return {}
@ -54,15 +60,19 @@ class AccountingBridge:
"""Load config, decrypt, resolve connector. Returns (connector, plainConfig, accountingConfigRecord).""" """Load config, decrypt, resolve connector. Returns (connector, plainConfig, accountingConfigRecord)."""
configRecord = await self.getActiveConfig(featureInstanceId) configRecord = await self.getActiveConfig(featureInstanceId)
if not configRecord: if not configRecord:
logger.warning(f"No active accounting config for instance {featureInstanceId}")
return None, None, None return None, None, None
connectorType = configRecord.get("connectorType") connectorType = configRecord.get("connectorType")
logger.info(f"Resolving connector '{connectorType}' for instance {featureInstanceId}")
connector = self._registry.getConnector(connectorType) connector = self._registry.getConnector(connectorType)
if not connector: 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 return None, None, configRecord
plainConfig = self._decryptConfig(configRecord.get("encryptedConfig", "")) plainConfig = self._decryptConfig(configRecord.get("encryptedConfig", ""))
if not plainConfig:
logger.error(f"Decrypted config is empty for connector '{connectorType}'")
return connector, plainConfig, configRecord return connector, plainConfig, configRecord
def _buildBookingFromPosition(self, position: Dict[str, Any]) -> AccountingBooking: def _buildBookingFromPosition(self, position: Dict[str, Any]) -> AccountingBooking:

View file

@ -51,25 +51,35 @@ class AccountingConnectorRma(BaseAccountingConnector):
] ]
def _buildUrl(self, config: Dict[str, Any], resource: str) -> str: def _buildUrl(self, config: Dict[str, Any], resource: str) -> str:
clientName = config["clientName"] clientName = config.get("clientName", "")
return f"{_BASE_URL}/{clientName}/{resource}" return f"{_BASE_URL}/{clientName}/{resource}"
def _buildHeaders(self, config: Dict[str, Any]) -> Dict[str, str]: 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 { return {
"X-RMA-KEY": config["apiKey"], "Authorization": f"Bearer {apiKey}",
"Accept": "application/json", "Accept": "application/json",
"Content-Type": "application/json", "Content-Type": "application/json",
} }
async def testConnection(self, config: Dict[str, Any]) -> SyncResult: 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: try:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
url = self._buildUrl(config, "customers") async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=15)) as resp:
async with session.get(url, headers=self._buildHeaders(config), timeout=aiohttp.ClientTimeout(total=15)) as resp:
if resp.status == 200: if resp.status == 200:
logger.info("RMA connection successful")
return SyncResult(success=True) return SyncResult(success=True)
body = await resp.text() 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: except Exception as e:
return SyncResult(success=False, errorMessage=str(e)) return SyncResult(success=False, errorMessage=str(e))
@ -181,7 +191,8 @@ class AccountingConnectorRma(BaseAccountingConnector):
formData = aiohttp.FormData() formData = aiohttp.FormData()
formData.add_field("Filedata", fileContent, filename=fileName, content_type=mimeType) 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: async with aiohttp.ClientSession() as session:
url = self._buildUrl(config, "belege") url = self._buildUrl(config, "belege")
async with session.post(url, headers=headers, data=formData, timeout=aiohttp.ClientTimeout(total=60)) as resp: async with session.post(url, headers=headers, data=formData, timeout=aiohttp.ClientTimeout(total=60)) as resp:

View file

@ -38,6 +38,11 @@ UI_OBJECTS = [
"label": {"en": "Expense Import", "de": "Spesen Import", "fr": "Import de dépenses"}, "label": {"en": "Expense Import", "de": "Spesen Import", "fr": "Import de dépenses"},
"meta": {"area": "expense-import"} "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", "objectKey": "ui.feature.trustee.instance-roles",
"label": {"en": "Instance Roles & Permissions", "de": "Instanz-Rollen & Berechtigungen", "fr": "Rôles et permissions d'instance"}, "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", "objectKey": "data.feature.trustee.TrusteeAccountingConfig",
"label": {"en": "Accounting Config", "de": "Buchhaltungs-Konfiguration", "fr": "Config. comptable"}, "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", "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.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.trustee.positions", "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.documents", "view": True},
{"context": "UI", "item": "ui.feature.trustee.settings", "view": True},
# Group-level DATA access # Group-level DATA access
{"context": "DATA", "item": None, "view": True, "read": "g", "create": "g", "update": "g", "delete": "g"}, {"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},
] ]
}, },
{ {

View file

@ -13,6 +13,7 @@ from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Quer
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
from fastapi import status from fastapi import status
from pydantic import BaseModel, Field
import logging import logging
import json import json
import io import io
@ -1179,6 +1180,23 @@ def get_available_accounting_connectors(
return _getAccountingRegistry().getAvailableConnectors() 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") @router.get("/{instanceId}/accounting/config")
@limiter.limit("30/minute") @limiter.limit("30/minute")
def get_accounting_config( def get_accounting_config(
@ -1186,57 +1204,104 @@ def get_accounting_config(
instanceId: str = Path(..., description="Feature Instance ID"), instanceId: str = Path(..., description="Feature Instance ID"),
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]: ) -> 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) mandateId = _validateInstanceAccess(instanceId, context)
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
from .datamodelFeatureTrustee import TrusteeAccountingConfig from .datamodelFeatureTrustee import TrusteeAccountingConfig
from modules.shared.configuration import decryptValue
records = interface.db.getRecordset(TrusteeAccountingConfig, recordFilter={"featureInstanceId": instanceId, "isActive": True}) records = interface.db.getRecordset(TrusteeAccountingConfig, recordFilter={"featureInstanceId": instanceId, "isActive": True})
if not records: if not records:
return {"configured": False} return {"configured": False}
record = {k: v for k, v in records[0].items() if not k.startswith("_")} 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 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 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) @router.post("/{instanceId}/accounting/config", status_code=201)
@limiter.limit("5/minute") @limiter.limit("5/minute")
def save_accounting_config( def save_accounting_config(
request: Request, request: Request,
instanceId: str = Path(..., description="Feature Instance ID"), instanceId: str = Path(..., description="Feature Instance ID"),
data: Dict[str, Any] = Body(...), body: SaveAccountingConfigBody = Body(...),
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Save or update the accounting config for this instance. """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) mandateId = _validateInstanceAccess(instanceId, context)
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
from .datamodelFeatureTrustee import TrusteeAccountingConfig from .datamodelFeatureTrustee import TrusteeAccountingConfig
from modules.shared.configuration import encryptValue from modules.shared.configuration import encryptValue
import json, uuid as _uuid import uuid as _uuid
plainConfig = data.get("config", {}) plainConfig = body.config if isinstance(body.config, dict) else {}
encryptedConfig = encryptValue(json.dumps(plainConfig), keyName="accountingConfig") # 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}) existing = interface.db.getRecordset(TrusteeAccountingConfig, recordFilter={"featureInstanceId": instanceId})
if existing: if existing:
configId = existing[0].get("id") configId = existing[0].get("id")
interface.db.recordModify(TrusteeAccountingConfig, configId, { updatePayload = {
"connectorType": data.get("connectorType", ""), "connectorType": body.connectorType or "",
"displayLabel": data.get("displayLabel", ""), "displayLabel": body.displayLabel or "",
"encryptedConfig": encryptedConfig,
"isActive": True, "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} 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 = { configRecord = {
"id": str(_uuid.uuid4()), "id": str(_uuid.uuid4()),
"featureInstanceId": instanceId, "featureInstanceId": instanceId,
"connectorType": data.get("connectorType", ""), "connectorType": body.connectorType or "",
"displayLabel": data.get("displayLabel", ""), "displayLabel": body.displayLabel or "",
"encryptedConfig": encryptedConfig, "encryptedConfig": encryptedConfig,
"isActive": True, "isActive": True,
"mandateId": mandateId, "mandateId": mandateId,

View file

@ -129,6 +129,7 @@ def get_my_feature_instances(
mandatesMap: Dict[str, Dict[str, Any]] = {} mandatesMap: Dict[str, Dict[str, Any]] = {}
featuresMap: Dict[str, Dict[str, Any]] = {} # key: mandateId_featureCode featuresMap: Dict[str, Dict[str, Any]] = {} # key: mandateId_featureCode
catalogService = getCatalogService()
for access in featureAccesses: for access in featureAccesses:
if not access.enabled: if not access.enabled:
continue continue
@ -137,6 +138,10 @@ def get_my_feature_instances(
if not instance or not instance.enabled: if not instance or not instance.enabled:
continue 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 # Get mandate info
mandateId = str(instance.mandateId) mandateId = str(instance.mandateId)
if mandateId not in mandatesMap: if mandateId not in mandatesMap:

View file

@ -174,8 +174,8 @@ def login(
# Save access token # Save access token
userInterface.saveAccessToken(token) userInterface.saveAccessToken(token)
# Log successful login # Log successful login (app log file + audit DB for traceability)
# MULTI-TENANT: Login is a system-level function, no mandate context logger.info("Login successful for username=%s (userId=%s)", formData.username, str(user.id))
try: try:
from modules.shared.auditLogger import audit_logger from modules.shared.auditLogger import audit_logger
audit_logger.logUserAccess( audit_logger.logUserAccess(

View file

@ -5,10 +5,16 @@
import logging import logging
import aiohttp import aiohttp
import asyncio import asyncio
import time
from typing import Dict, Any, List, Optional from typing import Dict, Any, List, Optional
logger = logging.getLogger(__name__) 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: class SharepointService:
"""SharePoint connector using Microsoft Graph API for reliable authentication.""" """SharePoint connector using Microsoft Graph API for reliable authentication."""

View file

@ -633,7 +633,7 @@ def _saveToTrusteePosition(
except Exception as e: except Exception as e:
logger.error(f"Error creating TrusteeDocument: {str(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: for record in records:
try: try:
position = { position = {
@ -649,6 +649,11 @@ def _saveToTrusteePosition(
"originalAmount": _parseFloat(record.get("originalAmount", 0)) or _parseFloat(record.get("bookingAmount", 0)), "originalAmount": _parseFloat(record.get("originalAmount", 0)) or _parseFloat(record.get("bookingAmount", 0)),
"vatPercentage": _parseFloat(record.get("vatPercentage", 0)), "vatPercentage": _parseFloat(record.get("vatPercentage", 0)),
"vatAmount": _parseFloat(record.get("vatAmount", 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, "featureInstanceId": featureInstanceId,
"mandateId": mandateId "mandateId": mandateId
} }
@ -662,6 +667,19 @@ def _saveToTrusteePosition(
except Exception as e: except Exception as e:
logger.error(f"Failed to save position: {str(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 return savedCount

View file

@ -35,6 +35,7 @@ MIGRATION_MAP: Dict[str, Dict[str, str]] = {
"dashboard": "ui.feature.trustee.dashboard", "dashboard": "ui.feature.trustee.dashboard",
"positions": "ui.feature.trustee.positions", "positions": "ui.feature.trustee.positions",
"documents": "ui.feature.trustee.documents", "documents": "ui.feature.trustee.documents",
"settings": "ui.feature.trustee.settings",
"instance-roles": "ui.feature.trustee.instance-roles", "instance-roles": "ui.feature.trustee.instance-roles",
# RESOURCE items # RESOURCE items
"instance-roles.manage": "resource.feature.trustee.instance-roles.manage", "instance-roles.manage": "resource.feature.trustee.instance-roles.manage",