353 lines
17 KiB
Python
353 lines
17 KiB
Python
# 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 json
|
||
import logging
|
||
import time
|
||
from datetime import datetime as _dt, timezone as _tz
|
||
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:
|
||
if not encryptedConfig:
|
||
logger.error("Accounting config encryptedConfig is empty")
|
||
return {}
|
||
decrypted = decryptValue(encryptedConfig, keyName="accountingConfig")
|
||
result = json.loads(decrypted) if isinstance(decrypted, str) else decrypted
|
||
configKeys = list(result.keys()) if isinstance(result, dict) else []
|
||
logger.info(f"Decrypted accounting config successfully, keys: {configKeys}")
|
||
return result
|
||
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:
|
||
logger.warning(f"No active accounting config for instance {featureInstanceId}")
|
||
return None, None, None
|
||
|
||
connectorType = configRecord.get("connectorType")
|
||
logger.info(f"Resolving connector '{connectorType}' for instance {featureInstanceId}")
|
||
connector = self._registry.getConnector(connectorType)
|
||
if not connector:
|
||
logger.error(f"Accounting connector '{connectorType}' not found in registry")
|
||
return None, None, configRecord
|
||
|
||
plainConfig = self._decryptConfig(configRecord.get("encryptedConfig", ""))
|
||
if not plainConfig:
|
||
logger.error(f"Decrypted config is empty for connector '{connectorType}'")
|
||
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"),
|
||
))
|
||
|
||
valutaTs = position.get("valuta")
|
||
bookingDateStr = _dt.fromtimestamp(valutaTs, tz=_tz.utc).strftime("%Y-%m-%d") if valutaTs else ""
|
||
|
||
return AccountingBooking(
|
||
reference=position.get("bookingReference") or position.get("id", ""),
|
||
bookingDate=bookingDateStr,
|
||
description=position.get("desc", ""),
|
||
lines=lines,
|
||
)
|
||
|
||
async def pushPositionToAccounting(
|
||
self,
|
||
featureInstanceId: str,
|
||
positionId: str,
|
||
_resolvedConnector=None,
|
||
_resolvedPlainConfig=None,
|
||
_resolvedConfigRecord=None,
|
||
) -> SyncResult:
|
||
"""Push a single position to the configured accounting system.
|
||
|
||
Optional _resolved* params allow pushBatchToAccounting to pass a pre-resolved
|
||
connector/config so we don't decrypt per position (avoids rate-limit).
|
||
"""
|
||
from modules.features.trustee.datamodelFeatureTrustee import TrusteePosition, TrusteeAccountingSync
|
||
|
||
connector = _resolvedConnector
|
||
plainConfig = _resolvedPlainConfig
|
||
configRecord = _resolvedConfigRecord
|
||
if not connector or not plainConfig:
|
||
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]
|
||
|
||
# Build booking; skip if position has no accounts (not ready for sync)
|
||
booking = self._buildBookingFromPosition(position)
|
||
if not booking.lines:
|
||
logger.info("Accounting sync skipped (no accounts): positionId=%s", positionId)
|
||
return SyncResult(success=True, errorMessage="Position hat keine Kontierung (Soll-/Haben-Konto) – Sync übersprungen")
|
||
|
||
# 1) First: ensure all documents are in RMA (upload or duplicate); collect Beleg-IDs for linking
|
||
documentIds = []
|
||
for key in ("documentId", "bankDocumentId"):
|
||
docId = position.get(key)
|
||
if docId:
|
||
documentIds.append(docId)
|
||
if documentIds:
|
||
from modules.features.trustee.datamodelFeatureTrustee import TrusteeDocument as TrusteeDocumentModel
|
||
logger.info("Accounting sync: positionId=%s, syncing %s document(s) to RMA ...", positionId, len(documentIds))
|
||
belegIds = []
|
||
belegLabels = []
|
||
for documentId in documentIds:
|
||
doc = self._trusteeInterface.getDocument(documentId)
|
||
if not doc:
|
||
continue
|
||
fileName = getattr(doc, "documentName", None) or "beleg.pdf"
|
||
existingBelegId = getattr(doc, "externalBelegId", None)
|
||
if existingBelegId:
|
||
logger.info("Accounting sync: document %s already has belegId=%s, skipping upload", documentId, existingBelegId)
|
||
belegIds.append(existingBelegId)
|
||
belegLabels.append(fileName)
|
||
continue
|
||
docData = self._trusteeInterface.getDocumentData(documentId)
|
||
if docData is None:
|
||
continue
|
||
mimeType = getattr(doc, "documentMimeType", None) or "application/pdf"
|
||
uploadResult = await connector.uploadDocument(
|
||
plainConfig,
|
||
fileName=fileName,
|
||
fileContent=docData,
|
||
mimeType=mimeType,
|
||
comment=booking.reference,
|
||
)
|
||
if not uploadResult.success:
|
||
errMsg = f"Dokument konnte nicht nach RMA hochgeladen werden: {uploadResult.errorMessage}"
|
||
logger.error(
|
||
"Accounting sync failed (document upload): positionId=%s, documentId=%s, error=%s",
|
||
positionId, documentId, uploadResult.errorMessage,
|
||
)
|
||
return SyncResult(success=False, errorMessage=errMsg)
|
||
belegId = uploadResult.externalId
|
||
if belegId:
|
||
self._trusteeInterface.db.recordModify(TrusteeDocumentModel, documentId, {"externalBelegId": belegId})
|
||
logger.info("Accounting sync: document uploaded & belegId=%s stored on document %s", belegId, documentId)
|
||
else:
|
||
logger.info("Accounting sync: document uploaded but no belegId in response (409 duplicate?), fileName=%s", fileName)
|
||
belegIds.append(belegId)
|
||
belegLabels.append(fileName)
|
||
if belegIds or belegLabels:
|
||
booking.externalDocumentIds = belegIds
|
||
booking.externalDocumentLabels = belegLabels
|
||
logger.info("Accounting sync: positionId=%s, document sync done, pushing GL booking (POST /gl) ...", positionId)
|
||
|
||
# Duplicate check: if locally marked as synced, verify with Buha system
|
||
accountingSyncId = position.get("accountingSyncId")
|
||
existingSyncs = self._trusteeInterface.db.getRecordset(
|
||
TrusteeAccountingSync,
|
||
recordFilter={"positionId": positionId, "connectorType": connectorType, "syncStatus": "synced"},
|
||
)
|
||
if accountingSyncId or existingSyncs:
|
||
checkResult = await connector.isBookingSynced(plainConfig, booking)
|
||
if checkResult.success:
|
||
logger.info(
|
||
"Accounting sync skipped (verified in Buha): positionId=%s, reference=%s",
|
||
positionId, booking.reference,
|
||
)
|
||
return SyncResult(success=False, errorMessage="Position already synced to this system")
|
||
# Not found in Buha (e.g. deleted there): clear local records and re-push
|
||
logger.info(
|
||
"Accounting sync: reference %s not found in Buha (deleted?), clearing local records and re-pushing positionId=%s",
|
||
booking.reference, positionId,
|
||
)
|
||
if accountingSyncId:
|
||
self._trusteeInterface.db.recordModify(TrusteePosition, positionId, {"accountingSyncId": None})
|
||
for rec in existingSyncs:
|
||
rid = rec.get("id")
|
||
if rid:
|
||
self._trusteeInterface.db.recordDelete(TrusteeAccountingSync, rid)
|
||
|
||
# 2) Then: push booking (with reference to document IDs so RMA can link)
|
||
if not documentIds:
|
||
logger.info("Accounting sync: positionId=%s, no documents, pushing GL booking (POST /gl) ...", positionId)
|
||
result = await connector.pushBooking(plainConfig, booking)
|
||
if not result.success:
|
||
logger.error(
|
||
"Accounting sync failed: positionId=%s, error=%s",
|
||
positionId,
|
||
result.errorMessage or "unknown",
|
||
)
|
||
|
||
# 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)
|
||
|
||
# Write back external ID to position (source of truth for sync check)
|
||
if result.success and result.externalId:
|
||
self._trusteeInterface.db.recordModify(
|
||
TrusteePosition, positionId, {"accountingSyncId": result.externalId}
|
||
)
|
||
|
||
# Update last sync on config record
|
||
if configRecord:
|
||
from modules.features.trustee.datamodelFeatureTrustee import TrusteeAccountingConfig
|
||
updatePayload = {
|
||
"lastSyncAt": time.time(),
|
||
"lastSyncStatus": "success" if result.success else "error",
|
||
}
|
||
if result.success:
|
||
updatePayload["lastSyncErrorMessage"] = None
|
||
else:
|
||
updatePayload["lastSyncErrorMessage"] = result.errorMessage or "Sync failed"
|
||
self._trusteeInterface.db.recordModify(TrusteeAccountingConfig, configRecord["id"], updatePayload)
|
||
|
||
return result
|
||
|
||
async def pushBatchToAccounting(self, featureInstanceId: str, positionIds: List[str]) -> List[SyncResult]:
|
||
"""Push multiple positions sequentially. Resolves connector/config once to avoid decrypt rate-limit."""
|
||
connector, plainConfig, configRecord = await self._resolveConnectorAndConfig(featureInstanceId)
|
||
if not connector or not plainConfig:
|
||
return [SyncResult(success=False, errorMessage="No active accounting configuration found") for _ in positionIds]
|
||
results = []
|
||
for positionId in positionIds:
|
||
result = await self.pushPositionToAccounting(
|
||
featureInstanceId, positionId,
|
||
_resolvedConnector=connector, _resolvedPlainConfig=plainConfig, _resolvedConfigRecord=configRecord,
|
||
)
|
||
results.append(result)
|
||
return results
|
||
|
||
async def refreshChartOfAccounts(self, featureInstanceId: str) -> List[AccountingChart]:
|
||
"""Fetch the full chart of accounts from the external system and cache it locally on TrusteeAccountingConfig."""
|
||
from modules.features.trustee.datamodelFeatureTrustee import TrusteeAccountingConfig
|
||
|
||
connector, plainConfig, configRecord = await self._resolveConnectorAndConfig(featureInstanceId)
|
||
if not connector or not plainConfig or not configRecord:
|
||
logger.warning("refreshChartOfAccounts: no connector/config — nothing to cache")
|
||
return []
|
||
|
||
charts = await connector.getChartOfAccounts(plainConfig)
|
||
serialised = json.dumps([{"accountNumber": c.accountNumber, "label": c.label, "accountType": c.accountType or ""} for c in charts], ensure_ascii=False)
|
||
self._trusteeInterface.db.recordModify(TrusteeAccountingConfig, configRecord["id"], {
|
||
"cachedChartOfAccounts": serialised,
|
||
"chartCachedAt": time.time(),
|
||
})
|
||
logger.info(f"Cached {len(charts)} chart-of-accounts entries for instance {featureInstanceId}")
|
||
return charts
|
||
|
||
def _readCachedCharts(self, configRecord: Dict[str, Any]) -> List[AccountingChart]:
|
||
"""Deserialise the cached chart-of-accounts JSON from a config record. Returns [] on any error."""
|
||
raw = configRecord.get("cachedChartOfAccounts")
|
||
if not raw:
|
||
return []
|
||
try:
|
||
items = json.loads(raw) if isinstance(raw, str) else raw
|
||
return [AccountingChart(accountNumber=i["accountNumber"], label=i["label"], accountType=i.get("accountType", "")) for i in items]
|
||
except Exception as e:
|
||
logger.debug("Could not deserialise cached chart: %s", e)
|
||
return []
|
||
|
||
async def getChartOfAccounts(self, featureInstanceId: str, accountType: Optional[str] = None) -> List[AccountingChart]:
|
||
"""Return chart of accounts — from local cache if available, otherwise fetch externally and cache."""
|
||
configRecord = await self.getActiveConfig(featureInstanceId)
|
||
if not configRecord:
|
||
return []
|
||
|
||
charts = self._readCachedCharts(configRecord)
|
||
if charts:
|
||
logger.debug(f"Using cached chart of accounts ({len(charts)} entries) for instance {featureInstanceId}")
|
||
else:
|
||
logger.info(f"No cached chart — fetching live for instance {featureInstanceId}")
|
||
charts = await self.refreshChartOfAccounts(featureInstanceId)
|
||
|
||
if accountType:
|
||
charts = [c for c in charts if c.accountType == accountType]
|
||
return charts
|
||
|
||
async def _getExpenseAccounts(self, featureInstanceId: str) -> List[AccountingChart]:
|
||
"""Load only expense accounts (Aufwandkonten) for use in AI prompts."""
|
||
return await self.getChartOfAccounts(featureInstanceId, accountType="expense")
|
||
|
||
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)
|