platform-core/modules/features/trustee/accounting/accountingBridge.py
ValueOn AG 4a60086c80
Some checks failed
Deploy Plattform-Core (Int) / test (push) Failing after 15s
Deploy Plattform-Core (Int) / deploy (push) Has been skipped
cp adapted to 2026 poweron
2026-06-09 09:53:31 +02:00

392 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Copyright (c) 2026 PowerOn AG
# 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
import uuid
from datetime import datetime, timezone
from typing import List, Dict, Any, Optional
from .accountingConnectorBase import (
AccountingBooking,
AccountingBookingLine,
AccountingChart,
SyncResult,
)
from .accountingRegistry import getAccountingRegistry
from modules.features.trustee.datamodelFeatureTrustee import TrusteeDocument
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
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 = datetime.fromtimestamp(valutaTs, tz=timezone.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")
# Collect document references
documentIds = []
for key in ("documentId", "bankDocumentId"):
docId = position.get(key)
if docId:
documentIds.append(docId)
pendingDocs = [] # [(documentId, fileName, fileContent, mimeType)] for post-booking attach
postBookingAttach = connector.requiresPostBookingDocAttach
# 1) Pre-booking document upload (RMA-style: upload first, link via belegId)
if documentIds and not postBookingAttach:
logger.info("Accounting sync: positionId=%s, uploading %s document(s) pre-booking ...", 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:
logger.error(
"Accounting sync failed (document upload): positionId=%s, documentId=%s, error=%s",
positionId, documentId, uploadResult.errorMessage,
)
return SyncResult(success=False, errorMessage=f"Dokument-Upload fehlgeschlagen: {uploadResult.errorMessage}")
belegId = uploadResult.externalId
if belegId:
self._trusteeInterface.db.recordModify(TrusteeDocument, documentId, {"externalBelegId": belegId})
logger.info("Accounting sync: document uploaded & belegId=%s stored on document %s", belegId, documentId)
belegIds.append(belegId)
belegLabels.append(fileName)
if belegIds or belegLabels:
booking.externalDocumentIds = belegIds
booking.externalDocumentLabels = belegLabels
logger.info("Accounting sync: positionId=%s, document upload done, pushing booking ...", positionId)
# 1b) Post-booking flow: collect raw doc data now, attach after pushBooking
if documentIds and postBookingAttach:
for documentId in documentIds:
doc = self._trusteeInterface.getDocument(documentId)
if not doc:
continue
existingBelegId = getattr(doc, "externalBelegId", None)
if existingBelegId:
continue
docData = self._trusteeInterface.getDocumentData(documentId)
if docData is None:
continue
fileName = getattr(doc, "documentName", None) or "beleg.pdf"
mimeType = getattr(doc, "documentMimeType", None) or "application/pdf"
pendingDocs.append((documentId, fileName, docData, mimeType))
if pendingDocs:
logger.info("Accounting sync: positionId=%s, %s document(s) queued for post-booking attach", positionId, len(pendingDocs))
# 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")
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) Push booking
if not documentIds:
logger.info("Accounting sync: positionId=%s, no documents, pushing booking ...", 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",
)
# 3) Post-booking document attach (Abacus-style: entry must exist before attaching docs)
if result.success and pendingDocs and result.externalId:
logger.info("Accounting sync: positionId=%s, attaching %s document(s) to entry %s ...", positionId, len(pendingDocs), result.externalId)
for documentId, fileName, docData, mimeType in pendingDocs:
attachResult = await connector.attachDocumentToEntry(
plainConfig,
entryId=result.externalId,
fileName=fileName,
fileContent=docData,
mimeType=mimeType,
)
if not attachResult.success:
logger.warning(
"Accounting sync: document attach failed (non-blocking): positionId=%s, documentId=%s, error=%s",
positionId, documentId, attachResult.errorMessage,
)
continue
if attachResult.externalId:
self._trusteeInterface.db.recordModify(TrusteeDocument, documentId, {"externalBelegId": attachResult.externalId})
logger.info("Accounting sync: document attached, externalId=%s stored on document %s", attachResult.externalId, documentId)
# Save sync record
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)