sync features a and p

This commit is contained in:
patrick-motsch 2026-02-19 00:31:32 +01:00
parent 2d7da8a66d
commit f9aa35647b
13 changed files with 1332 additions and 126 deletions

View file

@ -0,0 +1,2 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.

View file

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

View file

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

View file

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

View file

@ -0,0 +1,2 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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