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
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:

View file

@ -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:

View file

@ -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},
]
},
{

View file

@ -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,

View file

@ -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:

View file

@ -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(

View file

@ -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."""

View file

@ -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

View file

@ -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",