sync features a and p
This commit is contained in:
parent
2d7da8a66d
commit
f9aa35647b
13 changed files with 1332 additions and 126 deletions
2
modules/features/trustee/accounting/__init__.py
Normal file
2
modules/features/trustee/accounting/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
186
modules/features/trustee/accounting/accountingBridge.py
Normal file
186
modules/features/trustee/accounting/accountingBridge.py
Normal 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)
|
||||||
108
modules/features/trustee/accounting/accountingConnectorBase.py
Normal file
108
modules/features/trustee/accounting/accountingConnectorBase.py
Normal 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")
|
||||||
78
modules/features/trustee/accounting/accountingRegistry.py
Normal file
78
modules/features/trustee/accounting/accountingRegistry.py
Normal 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
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
|
|
@ -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 []
|
||||||
|
|
@ -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 []
|
||||||
|
|
@ -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))
|
||||||
|
|
@ -517,6 +517,51 @@ class TrusteePosition(BaseModel):
|
||||||
"frontend_required": False
|
"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(
|
mandateId: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Mandate ID (auto-set from context)",
|
description="Mandate ID (auto-set from context)",
|
||||||
|
|
@ -559,9 +604,81 @@ registerModelLabels(
|
||||||
"originalAmount": {"en": "Original Amount", "fr": "Montant d'origine", "de": "Originalbetrag"},
|
"originalAmount": {"en": "Original Amount", "fr": "Montant d'origine", "de": "Originalbetrag"},
|
||||||
"vatPercentage": {"en": "VAT Percentage", "fr": "Pourcentage TVA", "de": "MwSt-Prozentsatz"},
|
"vatPercentage": {"en": "VAT Percentage", "fr": "Pourcentage TVA", "de": "MwSt-Prozentsatz"},
|
||||||
"vatAmount": {"en": "VAT Amount", "fr": "Montant TVA", "de": "MwSt-Betrag"},
|
"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"},
|
"mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"},
|
||||||
"featureInstanceId": {"en": "Feature Instance", "fr": "Instance de fonctionnalité", "de": "Feature-Instanz"},
|
"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"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,16 @@ DATA_OBJECTS = [
|
||||||
"label": {"en": "Document", "de": "Dokument", "fr": "Document"},
|
"label": {"en": "Document", "de": "Dokument", "fr": "Document"},
|
||||||
"meta": {"table": "TrusteeDocument", "fields": ["id", "filename", "mimeType", "fileSize", "uploadDate"]}
|
"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.*",
|
"objectKey": "data.feature.trustee.*",
|
||||||
"label": {"en": "All Trustee Data", "de": "Alle Treuhand-Daten", "fr": "Toutes les données fiduciaires"},
|
"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"},
|
"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}
|
"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
|
# Template roles for this feature with AccessRules
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,6 @@ from .datamodelFeatureTrustee import (
|
||||||
TrusteeContract,
|
TrusteeContract,
|
||||||
TrusteeDocument,
|
TrusteeDocument,
|
||||||
TrusteePosition,
|
TrusteePosition,
|
||||||
TrusteePositionDocument,
|
|
||||||
)
|
)
|
||||||
from modules.datamodels.datamodelPagination import (
|
from modules.datamodels.datamodelPagination import (
|
||||||
PaginationParams,
|
PaginationParams,
|
||||||
|
|
@ -128,7 +127,6 @@ _TRUSTEE_ENTITY_MODELS = {
|
||||||
"TrusteeContract": TrusteeContract,
|
"TrusteeContract": TrusteeContract,
|
||||||
"TrusteeDocument": TrusteeDocument,
|
"TrusteeDocument": TrusteeDocument,
|
||||||
"TrusteePosition": TrusteePosition,
|
"TrusteePosition": TrusteePosition,
|
||||||
"TrusteePositionDocument": TrusteePositionDocument,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1166,146 +1164,228 @@ def delete_position(
|
||||||
return {"message": f"Position {positionId} deleted"}
|
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")
|
@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,
|
request: Request,
|
||||||
instanceId: str = Path(..., description="Feature Instance ID"),
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
||||||
pagination: Optional[str] = Query(None),
|
|
||||||
context: RequestContext = Depends(getRequestContext)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Get all position-document links with optional pagination.
|
"""Get the active accounting config for this instance (credentials redacted)."""
|
||||||
|
|
||||||
Each item includes _permissions: { canUpdate, canDelete } for row-level permission UI.
|
|
||||||
"""
|
|
||||||
mandateId = _validateInstanceAccess(instanceId, context)
|
mandateId = _validateInstanceAccess(instanceId, context)
|
||||||
|
|
||||||
paginationParams = _parsePagination(pagination)
|
|
||||||
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
||||||
result = interface.getAllPositionDocuments(paginationParams)
|
from .datamodelFeatureTrustee import TrusteeAccountingConfig
|
||||||
|
records = interface.db.getRecordset(TrusteeAccountingConfig, recordFilter={"featureInstanceId": instanceId, "isActive": True})
|
||||||
if paginationParams:
|
if not records:
|
||||||
return {
|
return {"configured": False}
|
||||||
"items": result.items,
|
record = {k: v for k, v in records[0].items() if not k.startswith("_")}
|
||||||
"pagination": {
|
record.pop("encryptedConfig", None)
|
||||||
"currentPage": paginationParams.page or 1,
|
record["configured"] = True
|
||||||
"pageSize": paginationParams.pageSize or 20,
|
return record
|
||||||
"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}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{instanceId}/position-documents/{linkId}", response_model=TrusteePositionDocument)
|
@router.post("/{instanceId}/accounting/config", status_code=201)
|
||||||
@limiter.limit("30/minute")
|
@limiter.limit("5/minute")
|
||||||
def get_position_document(
|
def save_accounting_config(
|
||||||
request: Request,
|
request: Request,
|
||||||
instanceId: str = Path(..., description="Feature Instance ID"),
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
||||||
linkId: str = Path(...),
|
data: Dict[str, Any] = Body(...),
|
||||||
context: RequestContext = Depends(getRequestContext)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> TrusteePositionDocument:
|
) -> Dict[str, Any]:
|
||||||
"""Get a single position-document link by ID."""
|
"""Save or update the accounting config for this instance.
|
||||||
|
|
||||||
|
Body: { connectorType, displayLabel, config: { ... plain credentials ... } }
|
||||||
|
"""
|
||||||
mandateId = _validateInstanceAccess(instanceId, context)
|
mandateId = _validateInstanceAccess(instanceId, context)
|
||||||
|
|
||||||
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
||||||
link = interface.getPositionDocument(linkId)
|
|
||||||
if not link:
|
from .datamodelFeatureTrustee import TrusteeAccountingConfig
|
||||||
raise HTTPException(status_code=404, detail=f"Link {linkId} not found")
|
from modules.shared.configuration import encryptValue
|
||||||
return link
|
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])
|
@router.post("/{instanceId}/accounting/test-connection")
|
||||||
@limiter.limit("30/minute")
|
@limiter.limit("5/minute")
|
||||||
def get_documents_for_position(
|
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,
|
request: Request,
|
||||||
instanceId: str = Path(..., description="Feature Instance ID"),
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
||||||
positionId: str = Path(...),
|
positionId: str = Path(...),
|
||||||
context: RequestContext = Depends(getRequestContext)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> List[TrusteePositionDocument]:
|
) -> Dict[str, Any]:
|
||||||
"""Get all document links for a position."""
|
"""Sync a single position to the accounting system."""
|
||||||
mandateId = _validateInstanceAccess(instanceId, context)
|
mandateId = _validateInstanceAccess(instanceId, context)
|
||||||
|
|
||||||
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
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")
|
@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,
|
request: Request,
|
||||||
instanceId: str = Path(..., description="Feature Instance ID"),
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
||||||
documentId: str = Path(...),
|
documentId: str = Path(...),
|
||||||
context: RequestContext = Depends(getRequestContext)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> List[TrusteePositionDocument]:
|
) -> List[TrusteePosition]:
|
||||||
"""Get all position links for a document."""
|
"""Get all positions generated from a specific document."""
|
||||||
mandateId = _validateInstanceAccess(instanceId, context)
|
mandateId = _validateInstanceAccess(instanceId, context)
|
||||||
|
|
||||||
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
||||||
return interface.getPositionsForDocument(documentId)
|
return interface.getPositionsByDocument(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"}
|
|
||||||
|
|
||||||
|
|
||||||
# ===== Instance Roles Management =====
|
# ===== Instance Roles Management =====
|
||||||
|
|
|
||||||
|
|
@ -633,10 +633,11 @@ def _saveToTrusteePosition(
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error creating TrusteeDocument: {str(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:
|
for record in records:
|
||||||
try:
|
try:
|
||||||
position = {
|
position = {
|
||||||
|
"documentId": documentId,
|
||||||
"valuta": record.get("valuta"),
|
"valuta": record.get("valuta"),
|
||||||
"transactionDateTime": record.get("transactionDateTime"),
|
"transactionDateTime": record.get("transactionDateTime"),
|
||||||
"company": record.get("company", ""),
|
"company": record.get("company", ""),
|
||||||
|
|
@ -661,21 +662,6 @@ def _saveToTrusteePosition(
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to save position: {str(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
|
return savedCount
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,6 @@ MIGRATION_MAP: Dict[str, Dict[str, str]] = {
|
||||||
"dashboard": "ui.feature.trustee.dashboard",
|
"dashboard": "ui.feature.trustee.dashboard",
|
||||||
"positions": "ui.feature.trustee.positions",
|
"positions": "ui.feature.trustee.positions",
|
||||||
"documents": "ui.feature.trustee.documents",
|
"documents": "ui.feature.trustee.documents",
|
||||||
"position-documents": "ui.feature.trustee.position-documents",
|
|
||||||
"instance-roles": "ui.feature.trustee.instance-roles",
|
"instance-roles": "ui.feature.trustee.instance-roles",
|
||||||
# RESOURCE items
|
# RESOURCE items
|
||||||
"instance-roles.manage": "resource.feature.trustee.instance-roles.manage",
|
"instance-roles.manage": "resource.feature.trustee.instance-roles.manage",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue