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
|
||||
}
|
||||
)
|
||||
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"},
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 =====
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue