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