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