# 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 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"), )) 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, _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)