From f9aa35647b2b5a2d3d991f60da26eff2605b89b2 Mon Sep 17 00:00:00 2001
From: patrick-motsch
Date: Thu, 19 Feb 2026 00:31:32 +0100
Subject: [PATCH] sync features a and p
---
.../features/trustee/accounting/__init__.py | 2 +
.../trustee/accounting/accountingBridge.py | 186 +++++++++++
.../accounting/accountingConnectorBase.py | 108 +++++++
.../trustee/accounting/accountingRegistry.py | 78 +++++
.../trustee/accounting/connectors/__init__.py | 2 +
.../connectors/accountingConnectorAbacus.py | 258 +++++++++++++++
.../connectors/accountingConnectorBexio.py | 172 ++++++++++
.../connectors/accountingConnectorRma.py | 193 ++++++++++++
.../trustee/datamodelFeatureTrustee.py | 117 +++++++
modules/features/trustee/mainTrustee.py | 25 ++
.../features/trustee/routeFeatureTrustee.py | 298 +++++++++++-------
.../actions/getExpensesFromPdf.py | 18 +-
...cript_db_migrate_accessrules_objectkeys.py | 1 -
13 files changed, 1332 insertions(+), 126 deletions(-)
create mode 100644 modules/features/trustee/accounting/__init__.py
create mode 100644 modules/features/trustee/accounting/accountingBridge.py
create mode 100644 modules/features/trustee/accounting/accountingConnectorBase.py
create mode 100644 modules/features/trustee/accounting/accountingRegistry.py
create mode 100644 modules/features/trustee/accounting/connectors/__init__.py
create mode 100644 modules/features/trustee/accounting/connectors/accountingConnectorAbacus.py
create mode 100644 modules/features/trustee/accounting/connectors/accountingConnectorBexio.py
create mode 100644 modules/features/trustee/accounting/connectors/accountingConnectorRma.py
diff --git a/modules/features/trustee/accounting/__init__.py b/modules/features/trustee/accounting/__init__.py
new file mode 100644
index 00000000..fdcc4f0e
--- /dev/null
+++ b/modules/features/trustee/accounting/__init__.py
@@ -0,0 +1,2 @@
+# Copyright (c) 2025 Patrick Motsch
+# All rights reserved.
diff --git a/modules/features/trustee/accounting/accountingBridge.py b/modules/features/trustee/accounting/accountingBridge.py
new file mode 100644
index 00000000..ce0b40fa
--- /dev/null
+++ b/modules/features/trustee/accounting/accountingBridge.py
@@ -0,0 +1,186 @@
+# Copyright (c) 2025 Patrick Motsch
+# All rights reserved.
+"""Accounting bridge: standardised interface between Trustee and external accounting systems.
+
+Encapsulates: config loading -> connector resolution -> duplicate check -> push -> sync record.
+"""
+
+import logging
+import time
+from typing import List, Dict, Any, Optional
+
+from .accountingConnectorBase import (
+ AccountingBooking,
+ AccountingBookingLine,
+ AccountingChart,
+ SyncResult,
+)
+from .accountingRegistry import _getAccountingRegistry
+
+logger = logging.getLogger(__name__)
+
+
+class AccountingBridge:
+ """Routes accounting operations through the correct connector for a feature instance."""
+
+ def __init__(self, trusteeInterface):
+ self._trusteeInterface = trusteeInterface
+ self._registry = _getAccountingRegistry()
+
+ async def getActiveConfig(self, featureInstanceId: str) -> Optional[Dict[str, Any]]:
+ """Load the active TrusteeAccountingConfig for a feature instance."""
+ from modules.features.trustee.datamodelFeatureTrustee import TrusteeAccountingConfig
+ records = self._trusteeInterface.db.getRecordset(
+ TrusteeAccountingConfig,
+ recordFilter={"featureInstanceId": featureInstanceId, "isActive": True},
+ )
+ if not records:
+ return None
+ record = records[0]
+ return {k: v for k, v in record.items() if not k.startswith("_")}
+
+ def _decryptConfig(self, encryptedConfig: str) -> Dict[str, Any]:
+ """Decrypt the stored connector config JSON."""
+ from modules.shared.configuration import decryptValue
+ import json
+ try:
+ decrypted = decryptValue(encryptedConfig, keyName="accountingConfig")
+ return json.loads(decrypted) if isinstance(decrypted, str) else decrypted
+ except Exception as e:
+ logger.error(f"Failed to decrypt accounting config: {e}")
+ return {}
+
+ async def _resolveConnectorAndConfig(self, featureInstanceId: str):
+ """Load config, decrypt, resolve connector. Returns (connector, plainConfig, accountingConfigRecord)."""
+ configRecord = await self.getActiveConfig(featureInstanceId)
+ if not configRecord:
+ return None, None, None
+
+ connectorType = configRecord.get("connectorType")
+ connector = self._registry.getConnector(connectorType)
+ if not connector:
+ logger.error(f"Accounting connector '{connectorType}' not found")
+ return None, None, configRecord
+
+ plainConfig = self._decryptConfig(configRecord.get("encryptedConfig", ""))
+ return connector, plainConfig, configRecord
+
+ def _buildBookingFromPosition(self, position: Dict[str, Any]) -> AccountingBooking:
+ """Build a standardised AccountingBooking from a TrusteePosition record."""
+ lines = []
+ debitAccount = position.get("debitAccountNumber")
+ creditAccount = position.get("creditAccountNumber")
+ amount = abs(position.get("bookingAmount", 0))
+
+ if debitAccount:
+ lines.append(AccountingBookingLine(
+ accountNumber=debitAccount,
+ debitAmount=amount,
+ currency=position.get("bookingCurrency", "CHF"),
+ taxCode=position.get("taxCode"),
+ taxRate=position.get("vatPercentage"),
+ description=position.get("desc", ""),
+ costCenter=position.get("costCenter"),
+ reference=position.get("bookingReference"),
+ ))
+ if creditAccount:
+ lines.append(AccountingBookingLine(
+ accountNumber=creditAccount,
+ creditAmount=amount,
+ currency=position.get("bookingCurrency", "CHF"),
+ description=position.get("desc", ""),
+ costCenter=position.get("costCenter"),
+ ))
+
+ return AccountingBooking(
+ reference=position.get("bookingReference") or position.get("id", ""),
+ bookingDate=position.get("valuta") or "",
+ description=position.get("desc", ""),
+ lines=lines,
+ )
+
+ async def pushPositionToAccounting(self, featureInstanceId: str, positionId: str) -> SyncResult:
+ """Push a single position to the configured accounting system.
+
+ 1. Load config and connector
+ 2. Load position data
+ 3. Check for existing successful sync (duplicate guard)
+ 4. Build AccountingBooking
+ 5. Push via connector
+ 6. Create TrusteeAccountingSync record
+ """
+ from modules.features.trustee.datamodelFeatureTrustee import TrusteePosition, TrusteeAccountingSync
+
+ connector, plainConfig, configRecord = await self._resolveConnectorAndConfig(featureInstanceId)
+ if not connector or not plainConfig:
+ return SyncResult(success=False, errorMessage="No active accounting configuration found")
+
+ connectorType = configRecord.get("connectorType", "")
+
+ # Load position
+ posRecords = self._trusteeInterface.db.getRecordset(TrusteePosition, recordFilter={"id": positionId})
+ if not posRecords:
+ return SyncResult(success=False, errorMessage=f"Position {positionId} not found")
+ position = posRecords[0]
+
+ # Duplicate check
+ existingSyncs = self._trusteeInterface.db.getRecordset(
+ TrusteeAccountingSync,
+ recordFilter={"positionId": positionId, "connectorType": connectorType, "syncStatus": "synced"},
+ )
+ if existingSyncs:
+ return SyncResult(success=False, errorMessage="Position already synced to this system")
+
+ # Build and push
+ booking = self._buildBookingFromPosition(position)
+ result = await connector.pushBooking(plainConfig, booking)
+
+ # Save sync record
+ import uuid
+ syncRecord = {
+ "id": str(uuid.uuid4()),
+ "positionId": positionId,
+ "featureInstanceId": featureInstanceId,
+ "connectorType": connectorType,
+ "externalId": result.externalId,
+ "externalReference": result.externalReference,
+ "syncStatus": "synced" if result.success else "error",
+ "syncDirection": "push",
+ "syncedAt": time.time() if result.success else None,
+ "errorMessage": result.errorMessage,
+ "bookingPayload": booking.model_dump(),
+ "mandateId": self._trusteeInterface.mandateId,
+ }
+ self._trusteeInterface.db.recordCreate(TrusteeAccountingSync, syncRecord)
+
+ # Update last sync on config record
+ if configRecord:
+ from modules.features.trustee.datamodelFeatureTrustee import TrusteeAccountingConfig
+ self._trusteeInterface.db.recordModify(TrusteeAccountingConfig, configRecord["id"], {
+ "lastSyncAt": time.time(),
+ "lastSyncStatus": "success" if result.success else "error",
+ })
+
+ return result
+
+ async def pushBatchToAccounting(self, featureInstanceId: str, positionIds: List[str]) -> List[SyncResult]:
+ """Push multiple positions sequentially."""
+ results = []
+ for positionId in positionIds:
+ result = await self.pushPositionToAccounting(featureInstanceId, positionId)
+ results.append(result)
+ return results
+
+ async def getChartOfAccounts(self, featureInstanceId: str) -> List[AccountingChart]:
+ """Load the chart of accounts from the configured external system."""
+ connector, plainConfig, _ = await self._resolveConnectorAndConfig(featureInstanceId)
+ if not connector or not plainConfig:
+ return []
+ return await connector.getChartOfAccounts(plainConfig)
+
+ async def testConnection(self, featureInstanceId: str) -> SyncResult:
+ """Test the connection with the configured accounting system."""
+ connector, plainConfig, _ = await self._resolveConnectorAndConfig(featureInstanceId)
+ if not connector or not plainConfig:
+ return SyncResult(success=False, errorMessage="No active accounting configuration found")
+ return await connector.testConnection(plainConfig)
diff --git a/modules/features/trustee/accounting/accountingConnectorBase.py b/modules/features/trustee/accounting/accountingConnectorBase.py
new file mode 100644
index 00000000..1a427e21
--- /dev/null
+++ b/modules/features/trustee/accounting/accountingConnectorBase.py
@@ -0,0 +1,108 @@
+# Copyright (c) 2025 Patrick Motsch
+# All rights reserved.
+"""Abstract base class and standard data models for accounting system connectors."""
+
+from abc import ABC, abstractmethod
+from typing import List, Optional, Dict, Any
+from pydantic import BaseModel, Field
+
+
+class AccountingBookingLine(BaseModel):
+ """System-independent booking line (one debit or credit entry)."""
+ accountNumber: str
+ accountLabel: Optional[str] = None
+ debitAmount: float = 0.0
+ creditAmount: float = 0.0
+ currency: str = "CHF"
+ taxCode: Optional[str] = None
+ taxRate: Optional[float] = None
+ description: str = ""
+ costCenter: Optional[str] = None
+ reference: Optional[str] = None
+
+
+class AccountingBooking(BaseModel):
+ """System-independent booking (journal entry): 1 booking = 1..N lines."""
+ externalId: Optional[str] = None
+ reference: str
+ bookingDate: str
+ description: str = ""
+ lines: List[AccountingBookingLine] = []
+
+
+class AccountingChart(BaseModel):
+ """Account from the chart of accounts."""
+ accountNumber: str
+ label: str
+ accountType: Optional[str] = None
+
+
+class SyncResult(BaseModel):
+ """Result of a sync operation."""
+ success: bool
+ externalId: Optional[str] = None
+ externalReference: Optional[str] = None
+ errorMessage: Optional[str] = None
+ rawResponse: Optional[Dict[str, Any]] = None
+
+
+class ConnectorConfigField(BaseModel):
+ """Describes a configuration field required by a connector."""
+ key: str
+ label: Dict[str, str]
+ fieldType: str = "text"
+ secret: bool = False
+ required: bool = True
+ placeholder: Optional[str] = None
+
+
+class BaseAccountingConnector(ABC):
+ """Abstract base for all accounting system connectors.
+
+ Each connector translates between the standardised AccountingBooking format
+ and the native API format of its target system.
+ """
+
+ @abstractmethod
+ def getConnectorType(self) -> str:
+ """Unique type identifier, e.g. 'rma', 'bexio', 'abacus'."""
+
+ @abstractmethod
+ def getConnectorLabel(self) -> Dict[str, str]:
+ """I18n display label."""
+
+ @abstractmethod
+ def getRequiredConfigFields(self) -> List[ConnectorConfigField]:
+ """Config fields the frontend must collect for this connector."""
+
+ @abstractmethod
+ async def testConnection(self, config: Dict[str, Any]) -> SyncResult:
+ """Verify the connection with the given credentials."""
+
+ @abstractmethod
+ async def getChartOfAccounts(self, config: Dict[str, Any]) -> List[AccountingChart]:
+ """Load the chart of accounts from the external system."""
+
+ @abstractmethod
+ async def pushBooking(self, config: Dict[str, Any], booking: AccountingBooking) -> SyncResult:
+ """Push a single booking to the external system."""
+
+ @abstractmethod
+ async def getBookingStatus(self, config: Dict[str, Any], externalId: str) -> SyncResult:
+ """Query the status of a previously pushed booking."""
+
+ async def pushInvoice(self, config: Dict[str, Any], invoice: Dict[str, Any]) -> SyncResult:
+ """Push an invoice. Override in connectors that support it."""
+ return SyncResult(success=False, errorMessage="Not supported by this connector")
+
+ async def getCustomers(self, config: Dict[str, Any]) -> List[Dict[str, Any]]:
+ """Load the customer list. Override in connectors that support it."""
+ return []
+
+ async def getVendors(self, config: Dict[str, Any]) -> List[Dict[str, Any]]:
+ """Load the vendor list. Override in connectors that support it."""
+ return []
+
+ async def uploadDocument(self, config: Dict[str, Any], fileName: str, fileContent: bytes, mimeType: str = "application/pdf") -> SyncResult:
+ """Upload a document/receipt. Override in connectors that support it."""
+ return SyncResult(success=False, errorMessage="Document upload not supported by this connector")
diff --git a/modules/features/trustee/accounting/accountingRegistry.py b/modules/features/trustee/accounting/accountingRegistry.py
new file mode 100644
index 00000000..4d0a3c1c
--- /dev/null
+++ b/modules/features/trustee/accounting/accountingRegistry.py
@@ -0,0 +1,78 @@
+# Copyright (c) 2025 Patrick Motsch
+# All rights reserved.
+"""Plugin-discovery registry for accounting connectors (analogous to aicoreModelRegistry)."""
+
+import glob
+import importlib
+import inspect
+import logging
+import os
+from typing import Dict, List, Optional
+
+from .accountingConnectorBase import BaseAccountingConnector
+
+logger = logging.getLogger(__name__)
+
+
+class AccountingRegistry:
+ """Discovers and manages accounting connector plugins."""
+
+ _connectors: Dict[str, BaseAccountingConnector] = {}
+ _discovered: bool = False
+
+ def discoverConnectors(self) -> int:
+ """Scan connectors/ for accountingConnector*.py files and register them."""
+ if self._discovered:
+ return len(self._connectors)
+
+ connectorsDir = os.path.join(os.path.dirname(__file__), "connectors")
+ pattern = os.path.join(connectorsDir, "accountingConnector*.py")
+
+ for filePath in glob.glob(pattern):
+ moduleName = os.path.splitext(os.path.basename(filePath))[0]
+ fullModuleName = f"modules.features.trustee.accounting.connectors.{moduleName}"
+ try:
+ module = importlib.import_module(fullModuleName)
+ for _name, obj in inspect.getmembers(module, inspect.isclass):
+ if issubclass(obj, BaseAccountingConnector) and obj is not BaseAccountingConnector:
+ instance = obj()
+ connectorType = instance.getConnectorType()
+ self._connectors[connectorType] = instance
+ logger.info(f"Registered accounting connector: {connectorType}")
+ except Exception as e:
+ logger.error(f"Failed to load accounting connector from {moduleName}: {e}")
+
+ self._discovered = True
+ logger.info(f"Discovered {len(self._connectors)} accounting connector(s)")
+ return len(self._connectors)
+
+ def getConnector(self, connectorType: str) -> Optional[BaseAccountingConnector]:
+ """Return the connector instance for the given type."""
+ if not self._discovered:
+ self.discoverConnectors()
+ return self._connectors.get(connectorType)
+
+ def getAvailableConnectors(self) -> List[Dict]:
+ """Return metadata for all available connectors (for frontend dropdown)."""
+ if not self._discovered:
+ self.discoverConnectors()
+ result = []
+ for connectorType, connector in self._connectors.items():
+ result.append({
+ "connectorType": connectorType,
+ "label": connector.getConnectorLabel(),
+ "configFields": [f.model_dump() for f in connector.getRequiredConfigFields()],
+ })
+ return result
+
+
+_registryInstance: Optional[AccountingRegistry] = None
+
+
+def _getAccountingRegistry() -> AccountingRegistry:
+ """Singleton access to the accounting registry."""
+ global _registryInstance
+ if _registryInstance is None:
+ _registryInstance = AccountingRegistry()
+ _registryInstance.discoverConnectors()
+ return _registryInstance
diff --git a/modules/features/trustee/accounting/connectors/__init__.py b/modules/features/trustee/accounting/connectors/__init__.py
new file mode 100644
index 00000000..fdcc4f0e
--- /dev/null
+++ b/modules/features/trustee/accounting/connectors/__init__.py
@@ -0,0 +1,2 @@
+# Copyright (c) 2025 Patrick Motsch
+# All rights reserved.
diff --git a/modules/features/trustee/accounting/connectors/accountingConnectorAbacus.py b/modules/features/trustee/accounting/connectors/accountingConnectorAbacus.py
new file mode 100644
index 00000000..66bb14f0
--- /dev/null
+++ b/modules/features/trustee/accounting/connectors/accountingConnectorAbacus.py
@@ -0,0 +1,258 @@
+# Copyright (c) 2025 Patrick Motsch
+# All rights reserved.
+"""Abacus ERP accounting connector.
+
+API docs: https://downloads.abacus.ch/fileadmin/ablage/abaconnect/htmlfiles/docs/restapi/abacus_rest_api.html
+Auth: OAuth 2.0 Client Credentials (Service User).
+Each Abacus instance has its own host URL; there is no central cloud endpoint.
+Entity API uses OData V4 format.
+"""
+
+import base64
+import logging
+import time
+from typing import List, Dict, Any, Optional
+
+import aiohttp
+
+from ..accountingConnectorBase import (
+ BaseAccountingConnector,
+ AccountingBooking,
+ AccountingChart,
+ ConnectorConfigField,
+ SyncResult,
+)
+
+logger = logging.getLogger(__name__)
+
+
+class AccountingConnectorAbacus(BaseAccountingConnector):
+
+ def __init__(self):
+ self._tokenCache: Dict[str, Dict[str, Any]] = {}
+
+ def getConnectorType(self) -> str:
+ return "abacus"
+
+ def getConnectorLabel(self) -> Dict[str, str]:
+ return {"en": "Abacus ERP", "de": "Abacus ERP", "fr": "Abacus ERP"}
+
+ def getRequiredConfigFields(self) -> List[ConnectorConfigField]:
+ return [
+ ConnectorConfigField(
+ key="abacusHost",
+ label={"en": "Abacus Host URL", "de": "Abacus Host-URL", "fr": "URL Hôte Abacus"},
+ fieldType="text",
+ secret=False,
+ placeholder="e.g. abacus.meinefirma.ch",
+ ),
+ ConnectorConfigField(
+ key="mandant",
+ label={"en": "Mandant Number", "de": "Mandantennummer", "fr": "Numéro de mandant"},
+ fieldType="text",
+ secret=False,
+ placeholder="e.g. 7777",
+ ),
+ ConnectorConfigField(
+ key="clientId",
+ label={"en": "Client ID", "de": "Client-ID", "fr": "ID Client"},
+ fieldType="text",
+ secret=False,
+ ),
+ ConnectorConfigField(
+ key="clientSecret",
+ label={"en": "Client Secret", "de": "Client-Secret", "fr": "Secret Client"},
+ fieldType="password",
+ secret=True,
+ ),
+ ]
+
+ def _buildBaseUrl(self, config: Dict[str, Any]) -> str:
+ host = config["abacusHost"].rstrip("/")
+ if not host.startswith("http"):
+ host = f"https://{host}"
+ return host
+
+ async def _getAccessToken(self, config: Dict[str, Any]) -> Optional[str]:
+ """Obtain an OAuth access token using client_credentials grant.
+
+ Tokens are cached and refreshed when expired (default 600s).
+ """
+ cacheKey = f"{config.get('abacusHost')}_{config.get('clientId')}"
+ cached = self._tokenCache.get(cacheKey)
+ if cached and cached.get("expiresAt", 0) > time.time() + 30:
+ return cached["accessToken"]
+
+ baseUrl = self._buildBaseUrl(config)
+
+ try:
+ async with aiohttp.ClientSession() as session:
+ # Step 1: discover token endpoint
+ async with session.get(f"{baseUrl}/.well-known/openid-configuration", timeout=aiohttp.ClientTimeout(total=10)) as resp:
+ if resp.status != 200:
+ logger.error(f"Abacus OIDC discovery failed: HTTP {resp.status}")
+ return None
+ oidc = await resp.json()
+
+ tokenEndpoint = oidc.get("token_endpoint")
+ if not tokenEndpoint:
+ logger.error("Abacus OIDC: no token_endpoint found")
+ return None
+
+ # Step 2: request token
+ credentials = base64.b64encode(f"{config['clientId']}:{config['clientSecret']}".encode()).decode()
+ headers = {"Authorization": f"Basic {credentials}", "Content-Type": "application/x-www-form-urlencoded"}
+ async with session.post(tokenEndpoint, headers=headers, data="grant_type=client_credentials", timeout=aiohttp.ClientTimeout(total=15)) as tokenResp:
+ if tokenResp.status != 200:
+ body = await tokenResp.text()
+ logger.error(f"Abacus token request failed: HTTP {tokenResp.status}: {body[:200]}")
+ return None
+ tokenData = await tokenResp.json()
+
+ accessToken = tokenData.get("access_token")
+ expiresIn = tokenData.get("expires_in", 600)
+ self._tokenCache[cacheKey] = {"accessToken": accessToken, "expiresAt": time.time() + expiresIn}
+ return accessToken
+
+ except Exception as e:
+ logger.error(f"Abacus token acquisition error: {e}")
+ return None
+
+ def _buildEntityUrl(self, config: Dict[str, Any], entity: str) -> str:
+ baseUrl = self._buildBaseUrl(config)
+ mandant = config["mandant"]
+ return f"{baseUrl}/api/entity/v1/{mandant}/{entity}"
+
+ async def _buildAuthHeaders(self, config: Dict[str, Any]) -> Optional[Dict[str, str]]:
+ token = await self._getAccessToken(config)
+ if not token:
+ return None
+ return {"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"}
+
+ async def testConnection(self, config: Dict[str, Any]) -> SyncResult:
+ headers = await self._buildAuthHeaders(config)
+ if not headers:
+ return SyncResult(success=False, errorMessage="Failed to obtain access token")
+ try:
+ async with aiohttp.ClientSession() as session:
+ url = self._buildEntityUrl(config, "Accounts?$top=1")
+ async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=15)) as resp:
+ if resp.status == 200:
+ return SyncResult(success=True)
+ body = await resp.text()
+ return SyncResult(success=False, errorMessage=f"HTTP {resp.status}: {body[:200]}")
+ except Exception as e:
+ return SyncResult(success=False, errorMessage=str(e))
+
+ async def getChartOfAccounts(self, config: Dict[str, Any]) -> List[AccountingChart]:
+ headers = await self._buildAuthHeaders(config)
+ if not headers:
+ return []
+
+ charts: List[AccountingChart] = []
+ url: Optional[str] = self._buildEntityUrl(config, "Accounts")
+
+ try:
+ async with aiohttp.ClientSession() as session:
+ while url:
+ async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=30)) as resp:
+ if resp.status != 200:
+ break
+ data = await resp.json()
+
+ for item in data.get("value", []):
+ charts.append(AccountingChart(
+ accountNumber=str(item.get("AccountNumber", item.get("Id", ""))),
+ label=item.get("Name", item.get("Description", "")),
+ accountType=item.get("AccountType", None),
+ ))
+ url = data.get("@odata.nextLink")
+ except Exception as e:
+ logger.error(f"Abacus getChartOfAccounts error: {e}")
+ return charts
+
+ async def pushBooking(self, config: Dict[str, Any], booking: AccountingBooking) -> SyncResult:
+ headers = await self._buildAuthHeaders(config)
+ if not headers:
+ return SyncResult(success=False, errorMessage="Failed to obtain access token")
+
+ try:
+ lines = []
+ for line in booking.lines:
+ entry: Dict[str, Any] = {
+ "AccountId": line.accountNumber,
+ "Text": line.description or booking.description,
+ }
+ if line.debitAmount > 0:
+ entry["DebitAmount"] = line.debitAmount
+ if line.creditAmount > 0:
+ entry["CreditAmount"] = line.creditAmount
+ if line.taxCode:
+ entry["TaxCode"] = line.taxCode
+ if line.costCenter:
+ entry["CostCenterId"] = line.costCenter
+ lines.append(entry)
+
+ payload = {
+ "JournalDate": booking.bookingDate,
+ "Reference": booking.reference,
+ "Text": booking.description,
+ "Lines": lines,
+ }
+
+ async with aiohttp.ClientSession() as session:
+ url = self._buildEntityUrl(config, "GeneralJournalEntries")
+ async with session.post(url, headers=headers, json=payload, timeout=aiohttp.ClientTimeout(total=30)) as resp:
+ body = await resp.json() if resp.content_type and "json" in resp.content_type else {"raw": await resp.text()}
+ if resp.status in (200, 201):
+ externalId = str(body.get("Id", "")) if isinstance(body, dict) else None
+ return SyncResult(success=True, externalId=externalId, rawResponse=body)
+ return SyncResult(success=False, errorMessage=f"HTTP {resp.status}", rawResponse=body)
+ except Exception as e:
+ return SyncResult(success=False, errorMessage=str(e))
+
+ async def getBookingStatus(self, config: Dict[str, Any], externalId: str) -> SyncResult:
+ headers = await self._buildAuthHeaders(config)
+ if not headers:
+ return SyncResult(success=False, errorMessage="Failed to obtain access token")
+ try:
+ async with aiohttp.ClientSession() as session:
+ url = self._buildEntityUrl(config, f"GeneralJournalEntries({externalId})")
+ async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=15)) as resp:
+ if resp.status == 200:
+ return SyncResult(success=True, externalId=externalId)
+ return SyncResult(success=False, errorMessage=f"HTTP {resp.status}")
+ except Exception as e:
+ return SyncResult(success=False, errorMessage=str(e))
+
+ async def getCustomers(self, config: Dict[str, Any]) -> List[Dict[str, Any]]:
+ headers = await self._buildAuthHeaders(config)
+ if not headers:
+ return []
+ try:
+ async with aiohttp.ClientSession() as session:
+ url = self._buildEntityUrl(config, "Debtors")
+ async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=30)) as resp:
+ if resp.status != 200:
+ return []
+ data = await resp.json()
+ return data.get("value", [])
+ except Exception as e:
+ logger.error(f"Abacus getCustomers error: {e}")
+ return []
+
+ async def getVendors(self, config: Dict[str, Any]) -> List[Dict[str, Any]]:
+ headers = await self._buildAuthHeaders(config)
+ if not headers:
+ return []
+ try:
+ async with aiohttp.ClientSession() as session:
+ url = self._buildEntityUrl(config, "Creditors")
+ async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=30)) as resp:
+ if resp.status != 200:
+ return []
+ data = await resp.json()
+ return data.get("value", [])
+ except Exception as e:
+ logger.error(f"Abacus getVendors error: {e}")
+ return []
diff --git a/modules/features/trustee/accounting/connectors/accountingConnectorBexio.py b/modules/features/trustee/accounting/connectors/accountingConnectorBexio.py
new file mode 100644
index 00000000..183d1bcc
--- /dev/null
+++ b/modules/features/trustee/accounting/connectors/accountingConnectorBexio.py
@@ -0,0 +1,172 @@
+# Copyright (c) 2025 Patrick Motsch
+# All rights reserved.
+"""Bexio accounting connector.
+
+API docs: https://docs.bexio.com/
+Auth: Personal Access Token (PAT) as Bearer token.
+Base URL: https://api.bexio.com/
+Note: Bexio uses internal account IDs (int), not account numbers.
+The connector caches the chart of accounts to resolve accountNumber -> account_id.
+"""
+
+import logging
+from typing import List, Dict, Any, Optional
+
+import aiohttp
+
+from ..accountingConnectorBase import (
+ BaseAccountingConnector,
+ AccountingBooking,
+ AccountingChart,
+ ConnectorConfigField,
+ SyncResult,
+)
+
+logger = logging.getLogger(__name__)
+
+_BASE_URL = "https://api.bexio.com"
+
+
+class AccountingConnectorBexio(BaseAccountingConnector):
+
+ def __init__(self):
+ self._chartCache: Dict[str, List[Dict[str, Any]]] = {}
+
+ def getConnectorType(self) -> str:
+ return "bexio"
+
+ def getConnectorLabel(self) -> Dict[str, str]:
+ return {"en": "Bexio", "de": "Bexio", "fr": "Bexio"}
+
+ def getRequiredConfigFields(self) -> List[ConnectorConfigField]:
+ return [
+ ConnectorConfigField(
+ key="accessToken",
+ label={"en": "Personal Access Token", "de": "Persönlicher Zugriffstoken", "fr": "Jeton d'accès personnel"},
+ fieldType="password",
+ secret=True,
+ placeholder="PAT from developer.bexio.com",
+ ),
+ ]
+
+ def _buildHeaders(self, config: Dict[str, Any]) -> Dict[str, str]:
+ return {
+ "Authorization": f"Bearer {config['accessToken']}",
+ "Accept": "application/json",
+ "Content-Type": "application/json",
+ }
+
+ async def testConnection(self, config: Dict[str, Any]) -> SyncResult:
+ try:
+ async with aiohttp.ClientSession() as session:
+ async with session.get(f"{_BASE_URL}/3.0/users/me", headers=self._buildHeaders(config), timeout=aiohttp.ClientTimeout(total=15)) as resp:
+ if resp.status == 200:
+ return SyncResult(success=True)
+ body = await resp.text()
+ return SyncResult(success=False, errorMessage=f"HTTP {resp.status}: {body[:200]}")
+ except Exception as e:
+ return SyncResult(success=False, errorMessage=str(e))
+
+ async def _loadRawAccounts(self, config: Dict[str, Any]) -> List[Dict[str, Any]]:
+ """Load raw account list and cache it for accountNumber -> id mapping."""
+ cacheKey = config.get("accessToken", "")[:16]
+ if cacheKey in self._chartCache:
+ return self._chartCache[cacheKey]
+
+ try:
+ async with aiohttp.ClientSession() as session:
+ async with session.get(f"{_BASE_URL}/2.0/accounts", headers=self._buildHeaders(config), timeout=aiohttp.ClientTimeout(total=30)) as resp:
+ if resp.status != 200:
+ return []
+ accounts = await resp.json()
+ self._chartCache[cacheKey] = accounts
+ return accounts
+ except Exception as e:
+ logger.error(f"Bexio loadRawAccounts error: {e}")
+ return []
+
+ def _resolveAccountId(self, accounts: List[Dict[str, Any]], accountNumber: str) -> Optional[int]:
+ """Resolve an account number string to a Bexio internal account_id."""
+ for acc in accounts:
+ if str(acc.get("account_no", "")) == accountNumber:
+ return acc.get("id")
+ return None
+
+ async def getChartOfAccounts(self, config: Dict[str, Any]) -> List[AccountingChart]:
+ accounts = await self._loadRawAccounts(config)
+ return [
+ AccountingChart(
+ accountNumber=str(acc.get("account_no", "")),
+ label=acc.get("name", ""),
+ accountType=acc.get("account_type", None),
+ )
+ for acc in accounts
+ ]
+
+ async def pushBooking(self, config: Dict[str, Any], booking: AccountingBooking) -> SyncResult:
+ """Push a manual entry to Bexio.
+
+ Bexio requires account_id (int) rather than account numbers, so we resolve
+ via the cached chart of accounts.
+ """
+ try:
+ accounts = await self._loadRawAccounts(config)
+
+ entries = []
+ for line in booking.lines:
+ debitAccId = self._resolveAccountId(accounts, line.accountNumber) if line.debitAmount > 0 else None
+ creditAccId = self._resolveAccountId(accounts, line.accountNumber) if line.creditAmount > 0 else None
+ amount = line.debitAmount if line.debitAmount > 0 else line.creditAmount
+
+ if debitAccId is None and creditAccId is None:
+ return SyncResult(success=False, errorMessage=f"Account {line.accountNumber} not found in Bexio chart")
+
+ entry: Dict[str, Any] = {"amount": str(amount), "description": line.description or booking.description}
+ if debitAccId:
+ entry["debit_account_id"] = debitAccId
+ if creditAccId:
+ entry["credit_account_id"] = creditAccId
+ if line.taxCode:
+ entry["tax_id"] = line.taxCode
+ entries.append(entry)
+
+ entryType = "manual_single_entry" if len(entries) == 1 else "manual_group_entry"
+ payload = {
+ "date": booking.bookingDate,
+ "reference_nr": booking.reference,
+ "type": entryType,
+ "entries": entries,
+ }
+
+ async with aiohttp.ClientSession() as session:
+ url = f"{_BASE_URL}/3.0/accounting/manual-entries"
+ async with session.post(url, headers=self._buildHeaders(config), json=payload, timeout=aiohttp.ClientTimeout(total=30)) as resp:
+ body = await resp.json() if resp.content_type == "application/json" else {"raw": await resp.text()}
+ if resp.status in (200, 201):
+ externalId = str(body.get("id", "")) if isinstance(body, dict) else None
+ return SyncResult(success=True, externalId=externalId, rawResponse=body)
+ return SyncResult(success=False, errorMessage=f"HTTP {resp.status}", rawResponse=body)
+ except Exception as e:
+ return SyncResult(success=False, errorMessage=str(e))
+
+ async def getBookingStatus(self, config: Dict[str, Any], externalId: str) -> SyncResult:
+ try:
+ async with aiohttp.ClientSession() as session:
+ url = f"{_BASE_URL}/3.0/accounting/manual-entries/{externalId}"
+ async with session.get(url, headers=self._buildHeaders(config), timeout=aiohttp.ClientTimeout(total=15)) as resp:
+ if resp.status == 200:
+ return SyncResult(success=True, externalId=externalId)
+ return SyncResult(success=False, errorMessage=f"HTTP {resp.status}")
+ except Exception as e:
+ return SyncResult(success=False, errorMessage=str(e))
+
+ async def getCustomers(self, config: Dict[str, Any]) -> List[Dict[str, Any]]:
+ try:
+ async with aiohttp.ClientSession() as session:
+ async with session.get(f"{_BASE_URL}/2.0/contact", headers=self._buildHeaders(config), timeout=aiohttp.ClientTimeout(total=30)) as resp:
+ if resp.status != 200:
+ return []
+ return await resp.json()
+ except Exception as e:
+ logger.error(f"Bexio getCustomers error: {e}")
+ return []
diff --git a/modules/features/trustee/accounting/connectors/accountingConnectorRma.py b/modules/features/trustee/accounting/connectors/accountingConnectorRma.py
new file mode 100644
index 00000000..b47e9849
--- /dev/null
+++ b/modules/features/trustee/accounting/connectors/accountingConnectorRma.py
@@ -0,0 +1,193 @@
+# Copyright (c) 2025 Patrick Motsch
+# All rights reserved.
+"""Run My Accounts (Infoniqa) accounting connector.
+
+API docs: https://runmyaccountsag.github.io/runmyaccounts-rest-api/
+Auth: Static API key via X-RMA-KEY header.
+Base URL: https://service.runmyaccounts.com/api/latest/clients/{clientName}/
+"""
+
+import logging
+from typing import List, Dict, Any
+
+import aiohttp
+
+from ..accountingConnectorBase import (
+ BaseAccountingConnector,
+ AccountingBooking,
+ AccountingChart,
+ ConnectorConfigField,
+ SyncResult,
+)
+
+logger = logging.getLogger(__name__)
+
+_BASE_URL = "https://service.runmyaccounts.com/api/latest/clients"
+
+
+class AccountingConnectorRma(BaseAccountingConnector):
+
+ def getConnectorType(self) -> str:
+ return "rma"
+
+ def getConnectorLabel(self) -> Dict[str, str]:
+ return {"en": "Run My Accounts", "de": "Run My Accounts", "fr": "Run My Accounts"}
+
+ def getRequiredConfigFields(self) -> List[ConnectorConfigField]:
+ return [
+ ConnectorConfigField(
+ key="clientName",
+ label={"en": "Client Name", "de": "Mandantenname", "fr": "Nom du client"},
+ fieldType="text",
+ secret=False,
+ placeholder="e.g. meinefirma",
+ ),
+ ConnectorConfigField(
+ key="apiKey",
+ label={"en": "API Key", "de": "API-Schlüssel", "fr": "Clé API"},
+ fieldType="password",
+ secret=True,
+ ),
+ ]
+
+ def _buildUrl(self, config: Dict[str, Any], resource: str) -> str:
+ clientName = config["clientName"]
+ return f"{_BASE_URL}/{clientName}/{resource}"
+
+ def _buildHeaders(self, config: Dict[str, Any]) -> Dict[str, str]:
+ return {
+ "X-RMA-KEY": config["apiKey"],
+ "Accept": "application/json",
+ "Content-Type": "application/json",
+ }
+
+ async def testConnection(self, config: Dict[str, Any]) -> SyncResult:
+ 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:
+ if resp.status == 200:
+ return SyncResult(success=True)
+ body = await resp.text()
+ return SyncResult(success=False, errorMessage=f"HTTP {resp.status}: {body[:200]}")
+ except Exception as e:
+ return SyncResult(success=False, errorMessage=str(e))
+
+ async def getChartOfAccounts(self, config: Dict[str, Any]) -> List[AccountingChart]:
+ try:
+ async with aiohttp.ClientSession() as session:
+ url = self._buildUrl(config, "charts")
+ async with session.get(url, headers=self._buildHeaders(config), timeout=aiohttp.ClientTimeout(total=30)) as resp:
+ if resp.status != 200:
+ logger.error(f"RMA charts failed: HTTP {resp.status}")
+ return []
+ data = await resp.json()
+
+ charts = []
+ items = data if isinstance(data, list) else data.get("chart", data.get("row", []))
+ for item in items:
+ if isinstance(item, dict):
+ accNo = item.get("accno", item.get("account_number", ""))
+ label = item.get("description", item.get("label", ""))
+ charts.append(AccountingChart(accountNumber=str(accNo), label=str(label)))
+ return charts
+ except Exception as e:
+ logger.error(f"RMA getChartOfAccounts error: {e}")
+ return []
+
+ async def pushBooking(self, config: Dict[str, Any], booking: AccountingBooking) -> SyncResult:
+ """Push a GL batch booking to RMA."""
+ try:
+ entries = []
+ for line in booking.lines:
+ entry = {
+ "accno": line.accountNumber,
+ "transdate": booking.bookingDate,
+ "reference": booking.reference,
+ "description": line.description or booking.description,
+ }
+ if line.debitAmount > 0:
+ entry["debit"] = line.debitAmount
+ if line.creditAmount > 0:
+ entry["credit"] = line.creditAmount
+ if line.taxCode:
+ entry["tax_code"] = line.taxCode
+ entries.append(entry)
+
+ payload = {"gl_batch": {"entry": entries}}
+
+ async with aiohttp.ClientSession() as session:
+ url = self._buildUrl(config, "gl")
+ async with session.post(url, headers=self._buildHeaders(config), json=payload, timeout=aiohttp.ClientTimeout(total=30)) as resp:
+ body = await resp.text()
+ if resp.status in (200, 201, 204):
+ return SyncResult(success=True, rawResponse={"status": resp.status, "body": body[:500]})
+ return SyncResult(success=False, errorMessage=f"HTTP {resp.status}: {body[:300]}")
+ except Exception as e:
+ return SyncResult(success=False, errorMessage=str(e))
+
+ async def getBookingStatus(self, config: Dict[str, Any], externalId: str) -> SyncResult:
+ try:
+ async with aiohttp.ClientSession() as session:
+ url = self._buildUrl(config, f"charts/{externalId}/transactions")
+ async with session.get(url, headers=self._buildHeaders(config), timeout=aiohttp.ClientTimeout(total=15)) as resp:
+ if resp.status == 200:
+ return SyncResult(success=True, externalId=externalId)
+ return SyncResult(success=False, errorMessage=f"HTTP {resp.status}")
+ except Exception as e:
+ return SyncResult(success=False, errorMessage=str(e))
+
+ async def pushInvoice(self, config: Dict[str, Any], invoice: Dict[str, Any]) -> SyncResult:
+ try:
+ async with aiohttp.ClientSession() as session:
+ url = self._buildUrl(config, "invoices")
+ async with session.post(url, headers=self._buildHeaders(config), json=invoice, timeout=aiohttp.ClientTimeout(total=30)) as resp:
+ body = await resp.text()
+ if resp.status in (200, 201):
+ return SyncResult(success=True, rawResponse={"body": body[:500]})
+ return SyncResult(success=False, errorMessage=f"HTTP {resp.status}: {body[:300]}")
+ except Exception as e:
+ return SyncResult(success=False, errorMessage=str(e))
+
+ async def getCustomers(self, config: Dict[str, Any]) -> List[Dict[str, Any]]:
+ 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=30)) as resp:
+ if resp.status != 200:
+ return []
+ data = await resp.json()
+ return data if isinstance(data, list) else data.get("customer", [])
+ except Exception as e:
+ logger.error(f"RMA getCustomers error: {e}")
+ return []
+
+ async def getVendors(self, config: Dict[str, Any]) -> List[Dict[str, Any]]:
+ try:
+ async with aiohttp.ClientSession() as session:
+ url = self._buildUrl(config, "vendors")
+ async with session.get(url, headers=self._buildHeaders(config), timeout=aiohttp.ClientTimeout(total=30)) as resp:
+ if resp.status != 200:
+ return []
+ data = await resp.json()
+ return data if isinstance(data, list) else data.get("vendor", [])
+ except Exception as e:
+ logger.error(f"RMA getVendors error: {e}")
+ return []
+
+ async def uploadDocument(self, config: Dict[str, Any], fileName: str, fileContent: bytes, mimeType: str = "application/pdf") -> SyncResult:
+ """Upload a receipt via POST /belege (multipart/form-data)."""
+ try:
+ formData = aiohttp.FormData()
+ formData.add_field("Filedata", fileContent, filename=fileName, content_type=mimeType)
+
+ headers = {"X-RMA-KEY": config["apiKey"], "Accept": "application/json"}
+ 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:
+ body = await resp.text()
+ if resp.status in (200, 201):
+ return SyncResult(success=True, rawResponse={"body": body[:500]})
+ return SyncResult(success=False, errorMessage=f"HTTP {resp.status}: {body[:300]}")
+ except Exception as e:
+ return SyncResult(success=False, errorMessage=str(e))
diff --git a/modules/features/trustee/datamodelFeatureTrustee.py b/modules/features/trustee/datamodelFeatureTrustee.py
index e1282e3b..09643735 100644
--- a/modules/features/trustee/datamodelFeatureTrustee.py
+++ b/modules/features/trustee/datamodelFeatureTrustee.py
@@ -517,6 +517,51 @@ class TrusteePosition(BaseModel):
"frontend_required": False
}
)
+ debitAccountNumber: Optional[str] = Field(
+ default=None,
+ description="Debit account number (e.g. '4200' for expenses)",
+ json_schema_extra={
+ "frontend_type": "text",
+ "frontend_readonly": False,
+ "frontend_required": False
+ }
+ )
+ creditAccountNumber: Optional[str] = Field(
+ default=None,
+ description="Credit account number (e.g. '1020' for bank)",
+ json_schema_extra={
+ "frontend_type": "text",
+ "frontend_readonly": False,
+ "frontend_required": False
+ }
+ )
+ taxCode: Optional[str] = Field(
+ default=None,
+ description="Tax code for the accounting system",
+ json_schema_extra={
+ "frontend_type": "text",
+ "frontend_readonly": False,
+ "frontend_required": False
+ }
+ )
+ costCenter: Optional[str] = Field(
+ default=None,
+ description="Cost center identifier",
+ json_schema_extra={
+ "frontend_type": "text",
+ "frontend_readonly": False,
+ "frontend_required": False
+ }
+ )
+ bookingReference: Optional[str] = Field(
+ default=None,
+ description="Booking reference (e.g. voucher number)",
+ json_schema_extra={
+ "frontend_type": "text",
+ "frontend_readonly": False,
+ "frontend_required": False
+ }
+ )
mandateId: Optional[str] = Field(
default=None,
description="Mandate ID (auto-set from context)",
@@ -559,9 +604,81 @@ registerModelLabels(
"originalAmount": {"en": "Original Amount", "fr": "Montant d'origine", "de": "Originalbetrag"},
"vatPercentage": {"en": "VAT Percentage", "fr": "Pourcentage TVA", "de": "MwSt-Prozentsatz"},
"vatAmount": {"en": "VAT Amount", "fr": "Montant TVA", "de": "MwSt-Betrag"},
+ "debitAccountNumber": {"en": "Debit Account", "fr": "Compte débit", "de": "Soll-Konto"},
+ "creditAccountNumber": {"en": "Credit Account", "fr": "Compte crédit", "de": "Haben-Konto"},
+ "taxCode": {"en": "Tax Code", "fr": "Code TVA", "de": "Steuercode"},
+ "costCenter": {"en": "Cost Center", "fr": "Centre de coûts", "de": "Kostenstelle"},
+ "bookingReference": {"en": "Booking Reference", "fr": "Référence de réservation", "de": "Buchungsreferenz"},
"mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"},
"featureInstanceId": {"en": "Feature Instance", "fr": "Instance de fonctionnalité", "de": "Feature-Instanz"},
},
)
+class TrusteeAccountingConfig(BaseModel):
+ """Per-instance accounting system configuration with encrypted credentials.
+
+ Each feature instance can connect to exactly one accounting system.
+ Credentials are stored encrypted (decrypted at runtime by the AccountingBridge).
+ """
+ id: str = Field(default_factory=lambda: str(uuid.uuid4()))
+ featureInstanceId: str = Field(description="FK -> FeatureInstance.id (1:1)")
+ connectorType: str = Field(description="Connector type key, e.g. 'rma', 'bexio', 'abacus'")
+ displayLabel: str = Field(default="", description="User-visible label for this integration")
+ encryptedConfig: str = Field(default="", description="Encrypted JSON blob with connector credentials")
+ isActive: bool = Field(default=True)
+ lastSyncAt: Optional[float] = Field(default=None, description="Timestamp of last sync attempt")
+ lastSyncStatus: Optional[str] = Field(default=None, description="Last sync result: success, error, partial")
+ mandateId: Optional[str] = Field(default=None)
+
+
+registerModelLabels(
+ "TrusteeAccountingConfig",
+ {"en": "Accounting Configuration", "de": "Buchhaltungs-Konfiguration", "fr": "Configuration comptable"},
+ {
+ "id": {"en": "ID", "fr": "ID", "de": "ID"},
+ "featureInstanceId": {"en": "Feature Instance", "fr": "Instance", "de": "Feature-Instanz"},
+ "connectorType": {"en": "System", "fr": "Système", "de": "System"},
+ "displayLabel": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"},
+ "isActive": {"en": "Active", "fr": "Actif", "de": "Aktiv"},
+ "lastSyncAt": {"en": "Last Sync", "fr": "Dernière sync.", "de": "Letzte Synchronisation"},
+ "lastSyncStatus": {"en": "Status", "fr": "Statut", "de": "Status"},
+ "mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"},
+ },
+)
+
+
+class TrusteeAccountingSync(BaseModel):
+ """Tracks which position was synced to which external system and when.
+
+ Used for duplicate prevention, audit trail, and retry logic.
+ """
+ id: str = Field(default_factory=lambda: str(uuid.uuid4()))
+ positionId: str = Field(description="FK -> TrusteePosition.id")
+ featureInstanceId: str = Field(description="FK -> FeatureInstance.id")
+ connectorType: str = Field(description="Connector type at time of sync")
+ externalId: Optional[str] = Field(default=None, description="ID assigned by the external system")
+ externalReference: Optional[str] = Field(default=None, description="Reference in the external system")
+ syncStatus: str = Field(default="pending", description="pending | synced | error | cancelled")
+ syncDirection: str = Field(default="push", description="push (local->ext) or pull (ext->local)")
+ syncedAt: Optional[float] = Field(default=None, description="Timestamp of successful sync")
+ errorMessage: Optional[str] = Field(default=None)
+ bookingPayload: Optional[dict] = Field(default=None, description="Payload sent to the external system (audit)")
+ mandateId: Optional[str] = Field(default=None)
+
+
+registerModelLabels(
+ "TrusteeAccountingSync",
+ {"en": "Accounting Sync", "de": "Buchhaltungs-Synchronisation", "fr": "Synchronisation comptable"},
+ {
+ "id": {"en": "ID", "fr": "ID", "de": "ID"},
+ "positionId": {"en": "Position", "fr": "Position", "de": "Position"},
+ "connectorType": {"en": "System", "fr": "Système", "de": "System"},
+ "externalId": {"en": "External ID", "fr": "ID Externe", "de": "Externe ID"},
+ "syncStatus": {"en": "Status", "fr": "Statut", "de": "Status"},
+ "syncDirection": {"en": "Direction", "fr": "Direction", "de": "Richtung"},
+ "syncedAt": {"en": "Synced At", "fr": "Synchronisé à", "de": "Synchronisiert am"},
+ "errorMessage": {"en": "Error", "fr": "Erreur", "de": "Fehler"},
+ "mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"},
+ },
+)
diff --git a/modules/features/trustee/mainTrustee.py b/modules/features/trustee/mainTrustee.py
index bfd443ef..04c010a0 100644
--- a/modules/features/trustee/mainTrustee.py
+++ b/modules/features/trustee/mainTrustee.py
@@ -58,6 +58,16 @@ DATA_OBJECTS = [
"label": {"en": "Document", "de": "Dokument", "fr": "Document"},
"meta": {"table": "TrusteeDocument", "fields": ["id", "filename", "mimeType", "fileSize", "uploadDate"]}
},
+ {
+ "objectKey": "data.feature.trustee.TrusteeAccountingConfig",
+ "label": {"en": "Accounting Config", "de": "Buchhaltungs-Konfiguration", "fr": "Config. comptable"},
+ "meta": {"table": "TrusteeAccountingConfig", "fields": ["id", "connectorType", "displayLabel", "isActive"]}
+ },
+ {
+ "objectKey": "data.feature.trustee.TrusteeAccountingSync",
+ "label": {"en": "Accounting Sync", "de": "Buchhaltungs-Synchronisation", "fr": "Sync. comptable"},
+ "meta": {"table": "TrusteeAccountingSync", "fields": ["id", "positionId", "syncStatus", "externalId"]}
+ },
{
"objectKey": "data.feature.trustee.*",
"label": {"en": "All Trustee Data", "de": "Alle Treuhand-Daten", "fr": "Toutes les données fiduciaires"},
@@ -103,6 +113,21 @@ RESOURCE_OBJECTS = [
"label": {"en": "Manage Instance Roles", "de": "Instanz-Rollen verwalten", "fr": "Gérer les rôles d'instance"},
"meta": {"endpoint": "/api/trustee/{instanceId}/instance-roles", "method": "ALL", "admin_only": True}
},
+ {
+ "objectKey": "resource.feature.trustee.accounting.manage",
+ "label": {"en": "Manage Accounting Integration", "de": "Buchhaltungs-Integration verwalten", "fr": "Gérer l'intégration comptable"},
+ "meta": {"endpoint": "/api/trustee/{instanceId}/accounting/config", "method": "ALL", "admin_only": True}
+ },
+ {
+ "objectKey": "resource.feature.trustee.accounting.sync",
+ "label": {"en": "Sync to Accounting", "de": "Buchhaltung synchronisieren", "fr": "Synchroniser la comptabilité"},
+ "meta": {"endpoint": "/api/trustee/{instanceId}/accounting/sync", "method": "POST"}
+ },
+ {
+ "objectKey": "resource.feature.trustee.accounting.view",
+ "label": {"en": "View Sync Status", "de": "Sync-Status einsehen", "fr": "Voir le statut de synchronisation"},
+ "meta": {"endpoint": "/api/trustee/{instanceId}/accounting/sync-status", "method": "GET"}
+ },
]
# Template roles for this feature with AccessRules
diff --git a/modules/features/trustee/routeFeatureTrustee.py b/modules/features/trustee/routeFeatureTrustee.py
index aa1f3246..2ad4da7e 100644
--- a/modules/features/trustee/routeFeatureTrustee.py
+++ b/modules/features/trustee/routeFeatureTrustee.py
@@ -29,7 +29,6 @@ from .datamodelFeatureTrustee import (
TrusteeContract,
TrusteeDocument,
TrusteePosition,
- TrusteePositionDocument,
)
from modules.datamodels.datamodelPagination import (
PaginationParams,
@@ -128,7 +127,6 @@ _TRUSTEE_ENTITY_MODELS = {
"TrusteeContract": TrusteeContract,
"TrusteeDocument": TrusteeDocument,
"TrusteePosition": TrusteePosition,
- "TrusteePositionDocument": TrusteePositionDocument,
}
@@ -1166,146 +1164,228 @@ def delete_position(
return {"message": f"Position {positionId} deleted"}
-# ===== Position-Document Link Routes =====
+# ===== Accounting Integration Endpoints =====
-@router.get("/{instanceId}/position-documents")
+@router.get("/{instanceId}/accounting/connectors")
@limiter.limit("30/minute")
-def get_position_documents(
+def get_available_accounting_connectors(
+ request: Request,
+ instanceId: str = Path(..., description="Feature Instance ID"),
+ context: RequestContext = Depends(getRequestContext)
+) -> List[Dict[str, Any]]:
+ """List all available accounting system connectors with their config fields."""
+ _validateInstanceAccess(instanceId, context)
+ from .accounting.accountingRegistry import _getAccountingRegistry
+ return _getAccountingRegistry().getAvailableConnectors()
+
+
+@router.get("/{instanceId}/accounting/config")
+@limiter.limit("30/minute")
+def get_accounting_config(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
- pagination: Optional[str] = Query(None),
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
- """Get all position-document links with optional pagination.
-
- Each item includes _permissions: { canUpdate, canDelete } for row-level permission UI.
- """
+ """Get the active accounting config for this instance (credentials redacted)."""
mandateId = _validateInstanceAccess(instanceId, context)
-
- paginationParams = _parsePagination(pagination)
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
- result = interface.getAllPositionDocuments(paginationParams)
-
- if paginationParams:
- return {
- "items": result.items,
- "pagination": {
- "currentPage": paginationParams.page or 1,
- "pageSize": paginationParams.pageSize or 20,
- "totalItems": result.totalItems,
- "totalPages": result.totalPages,
- "sort": paginationParams.sort if paginationParams else [],
- "filters": paginationParams.filters if paginationParams else None
- }
- }
- return {"items": result.items, "pagination": None}
+ from .datamodelFeatureTrustee import TrusteeAccountingConfig
+ 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)
+ record["configured"] = True
+ return record
-@router.get("/{instanceId}/position-documents/{linkId}", response_model=TrusteePositionDocument)
-@limiter.limit("30/minute")
-def get_position_document(
+@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"),
- linkId: str = Path(...),
+ data: Dict[str, Any] = Body(...),
context: RequestContext = Depends(getRequestContext)
-) -> TrusteePositionDocument:
- """Get a single position-document link by ID."""
+) -> Dict[str, Any]:
+ """Save or update the accounting config for this instance.
+
+ Body: { connectorType, displayLabel, config: { ... plain credentials ... } }
+ """
mandateId = _validateInstanceAccess(instanceId, context)
-
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
- link = interface.getPositionDocument(linkId)
- if not link:
- raise HTTPException(status_code=404, detail=f"Link {linkId} not found")
- return link
+
+ from .datamodelFeatureTrustee import TrusteeAccountingConfig
+ from modules.shared.configuration import encryptValue
+ import json, uuid as _uuid
+
+ plainConfig = data.get("config", {})
+ encryptedConfig = encryptValue(json.dumps(plainConfig), keyName="accountingConfig")
+
+ 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,
+ "isActive": True,
+ })
+ return {"message": "Accounting config updated", "id": configId}
+
+ configRecord = {
+ "id": str(_uuid.uuid4()),
+ "featureInstanceId": instanceId,
+ "connectorType": data.get("connectorType", ""),
+ "displayLabel": data.get("displayLabel", ""),
+ "encryptedConfig": encryptedConfig,
+ "isActive": True,
+ "mandateId": mandateId,
+ }
+ interface.db.recordCreate(TrusteeAccountingConfig, configRecord)
+ return {"message": "Accounting config created", "id": configRecord["id"]}
-@router.get("/{instanceId}/position-documents/position/{positionId}", response_model=List[TrusteePositionDocument])
-@limiter.limit("30/minute")
-def get_documents_for_position(
+@router.post("/{instanceId}/accounting/test-connection")
+@limiter.limit("5/minute")
+async def test_accounting_connection(
+ request: Request,
+ instanceId: str = Path(..., description="Feature Instance ID"),
+ context: RequestContext = Depends(getRequestContext)
+) -> Dict[str, Any]:
+ """Test the connection to the configured accounting system."""
+ mandateId = _validateInstanceAccess(instanceId, context)
+ interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
+ from .accounting.accountingBridge import AccountingBridge
+ bridge = AccountingBridge(interface)
+ result = await bridge.testConnection(instanceId)
+ return result.model_dump()
+
+
+@router.delete("/{instanceId}/accounting/config")
+@limiter.limit("5/minute")
+def delete_accounting_config(
+ request: Request,
+ instanceId: str = Path(..., description="Feature Instance ID"),
+ context: RequestContext = Depends(getRequestContext)
+) -> Dict[str, Any]:
+ """Remove the accounting integration for this instance."""
+ mandateId = _validateInstanceAccess(instanceId, context)
+ interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
+ from .datamodelFeatureTrustee import TrusteeAccountingConfig
+ records = interface.db.getRecordset(TrusteeAccountingConfig, recordFilter={"featureInstanceId": instanceId})
+ for r in records:
+ interface.db.recordDelete(TrusteeAccountingConfig, r.get("id"))
+ return {"message": "Accounting config removed"}
+
+
+@router.get("/{instanceId}/accounting/chart-of-accounts")
+@limiter.limit("10/minute")
+async def get_chart_of_accounts(
+ request: Request,
+ instanceId: str = Path(..., description="Feature Instance ID"),
+ context: RequestContext = Depends(getRequestContext)
+) -> List[Dict[str, Any]]:
+ """Load the chart of accounts from the connected accounting system."""
+ mandateId = _validateInstanceAccess(instanceId, context)
+ interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
+ from .accounting.accountingBridge import AccountingBridge
+ bridge = AccountingBridge(interface)
+ charts = await bridge.getChartOfAccounts(instanceId)
+ return [c.model_dump() for c in charts]
+
+
+@router.post("/{instanceId}/accounting/sync")
+@limiter.limit("5/minute")
+async def sync_positions_to_accounting(
+ request: Request,
+ instanceId: str = Path(..., description="Feature Instance ID"),
+ data: Dict[str, Any] = Body(...),
+ context: RequestContext = Depends(getRequestContext)
+) -> Dict[str, Any]:
+ """Sync positions to the accounting system. Body: { positionIds: [...] }"""
+ mandateId = _validateInstanceAccess(instanceId, context)
+ interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
+ from .accounting.accountingBridge import AccountingBridge
+ bridge = AccountingBridge(interface)
+
+ positionIds = data.get("positionIds", [])
+ if not positionIds:
+ raise HTTPException(status_code=400, detail="positionIds required")
+
+ results = await bridge.pushBatchToAccounting(instanceId, positionIds)
+ return {
+ "total": len(results),
+ "success": sum(1 for r in results if r.success),
+ "errors": sum(1 for r in results if not r.success),
+ "results": [r.model_dump() for r in results],
+ }
+
+
+@router.post("/{instanceId}/accounting/sync/{positionId}")
+@limiter.limit("10/minute")
+async def sync_single_position_to_accounting(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
positionId: str = Path(...),
context: RequestContext = Depends(getRequestContext)
-) -> List[TrusteePositionDocument]:
- """Get all document links for a position."""
+) -> Dict[str, Any]:
+ """Sync a single position to the accounting system."""
mandateId = _validateInstanceAccess(instanceId, context)
-
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
- return interface.getDocumentsForPosition(positionId)
+ from .accounting.accountingBridge import AccountingBridge
+ bridge = AccountingBridge(interface)
+ result = await bridge.pushPositionToAccounting(instanceId, positionId)
+ return result.model_dump()
-@router.get("/{instanceId}/position-documents/document/{documentId}", response_model=List[TrusteePositionDocument])
+@router.get("/{instanceId}/accounting/sync-status")
@limiter.limit("30/minute")
-def get_positions_for_document(
+def get_sync_status(
+ request: Request,
+ instanceId: str = Path(..., description="Feature Instance ID"),
+ context: RequestContext = Depends(getRequestContext)
+) -> Dict[str, Any]:
+ """Get sync status of all positions for this instance."""
+ mandateId = _validateInstanceAccess(instanceId, context)
+ interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
+ from .datamodelFeatureTrustee import TrusteeAccountingSync
+ records = interface.db.getRecordset(TrusteeAccountingSync, recordFilter={"featureInstanceId": instanceId})
+ items = [{k: v for k, v in r.items() if not k.startswith("_")} for r in records]
+ return {"items": items}
+
+
+@router.get("/{instanceId}/accounting/sync-status/{positionId}")
+@limiter.limit("30/minute")
+def get_position_sync_status(
+ request: Request,
+ instanceId: str = Path(..., description="Feature Instance ID"),
+ positionId: str = Path(...),
+ context: RequestContext = Depends(getRequestContext)
+) -> Dict[str, Any]:
+ """Get sync status for a specific position."""
+ mandateId = _validateInstanceAccess(instanceId, context)
+ interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
+ from .datamodelFeatureTrustee import TrusteeAccountingSync
+ records = interface.db.getRecordset(TrusteeAccountingSync, recordFilter={"positionId": positionId, "featureInstanceId": instanceId})
+ items = [{k: v for k, v in r.items() if not k.startswith("_")} for r in records]
+ return {"items": items}
+
+
+# ===== Position-Document Query =====
+
+@router.get("/{instanceId}/positions/document/{documentId}", response_model=List[TrusteePosition])
+@limiter.limit("30/minute")
+def get_positions_by_document(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
documentId: str = Path(...),
context: RequestContext = Depends(getRequestContext)
-) -> List[TrusteePositionDocument]:
- """Get all position links for a document."""
+) -> List[TrusteePosition]:
+ """Get all positions generated from a specific document."""
mandateId = _validateInstanceAccess(instanceId, context)
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
- return interface.getPositionsForDocument(documentId)
-
-
-@router.post("/{instanceId}/position-documents", response_model=TrusteePositionDocument, status_code=201)
-@limiter.limit("10/minute")
-def create_position_document(
- request: Request,
- instanceId: str = Path(..., description="Feature Instance ID"),
- data: TrusteePositionDocument = Body(...),
- context: RequestContext = Depends(getRequestContext)
-) -> TrusteePositionDocument:
- """Create a new position-document link."""
- mandateId = _validateInstanceAccess(instanceId, context)
-
- interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
- result = interface.createPositionDocument(data.model_dump())
- if not result:
- raise HTTPException(status_code=400, detail="Failed to create link")
- return result
-
-
-@router.put("/{instanceId}/position-documents/{linkId}", response_model=TrusteePositionDocument)
-@limiter.limit("10/minute")
-def update_position_document(
- request: Request,
- instanceId: str = Path(..., description="Feature Instance ID"),
- linkId: str = Path(...),
- data: TrusteePositionDocument = Body(...),
- context: RequestContext = Depends(getRequestContext)
-) -> TrusteePositionDocument:
- """Update a position-document link."""
- mandateId = _validateInstanceAccess(instanceId, context)
-
- interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
- result = interface.updatePositionDocument(linkId, data.model_dump(exclude_unset=True))
- if not result:
- raise HTTPException(status_code=400, detail="Failed to update link")
- return result
-
-
-@router.delete("/{instanceId}/position-documents/{linkId}")
-@limiter.limit("10/minute")
-def delete_position_document(
- request: Request,
- instanceId: str = Path(..., description="Feature Instance ID"),
- linkId: str = Path(...),
- context: RequestContext = Depends(getRequestContext)
-) -> Dict[str, Any]:
- """Delete a position-document link."""
- mandateId = _validateInstanceAccess(instanceId, context)
-
- interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
- existing = interface.getPositionDocument(linkId)
- if not existing:
- raise HTTPException(status_code=404, detail=f"Link {linkId} not found")
-
- success = interface.deletePositionDocument(linkId)
- if not success:
- raise HTTPException(status_code=400, detail="Failed to delete link")
- return {"message": f"Link {linkId} deleted"}
+ return interface.getPositionsByDocument(documentId)
# ===== Instance Roles Management =====
diff --git a/modules/workflows/methods/methodSharepoint/actions/getExpensesFromPdf.py b/modules/workflows/methods/methodSharepoint/actions/getExpensesFromPdf.py
index 21de7537..cd9742bd 100644
--- a/modules/workflows/methods/methodSharepoint/actions/getExpensesFromPdf.py
+++ b/modules/workflows/methods/methodSharepoint/actions/getExpensesFromPdf.py
@@ -633,10 +633,11 @@ def _saveToTrusteePosition(
except Exception as e:
logger.error(f"Error creating TrusteeDocument: {str(e)}")
- # Step 2: Save positions
+ # Step 2: Save positions with direct documentId reference
for record in records:
try:
position = {
+ "documentId": documentId,
"valuta": record.get("valuta"),
"transactionDateTime": record.get("transactionDateTime"),
"company": record.get("company", ""),
@@ -661,21 +662,6 @@ def _saveToTrusteePosition(
except Exception as e:
logger.error(f"Failed to save position: {str(e)}")
- # Step 3: Create Position-Document links
- if documentId and savedPositionIds:
- for positionId in savedPositionIds:
- try:
- link = trusteeInterface.createPositionDocument({
- "documentId": documentId,
- "positionId": positionId
- })
- if link:
- logger.debug(f"Created position-document link: {positionId} -> {documentId}")
- else:
- logger.warning(f"Failed to create position-document link: {positionId} -> {documentId}")
- except Exception as e:
- logger.error(f"Error creating position-document link: {str(e)}")
-
return savedCount
diff --git a/scripts/script_db_migrate_accessrules_objectkeys.py b/scripts/script_db_migrate_accessrules_objectkeys.py
index 840367e5..d3054aa1 100644
--- a/scripts/script_db_migrate_accessrules_objectkeys.py
+++ b/scripts/script_db_migrate_accessrules_objectkeys.py
@@ -35,7 +35,6 @@ MIGRATION_MAP: Dict[str, Dict[str, str]] = {
"dashboard": "ui.feature.trustee.dashboard",
"positions": "ui.feature.trustee.positions",
"documents": "ui.feature.trustee.documents",
- "position-documents": "ui.feature.trustee.position-documents",
"instance-roles": "ui.feature.trustee.instance-roles",
# RESOURCE items
"instance-roles.manage": "resource.feature.trustee.instance-roles.manage",