# 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: 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) -> 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] # Build booking once (for push; externalDocumentIds filled after document upload) booking = self._buildBookingFromPosition(position) # 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.""" 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)