# 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 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] = [] externalDocumentIds: Optional[List[str]] = None # e.g. RMA Beleg-IDs, sent before booking for linking externalDocumentLabels: Optional[List[str]] = None # display names for links (e.g. file names), one per id 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: str fieldType: str = "text" secret: bool = False required: bool = True placeholder: Optional[str] = None suggestions: Optional[List[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) -> str: """German plaintext label (used as i18n key).""" @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], accountType: Optional[str] = None) -> List[AccountingChart]: """Load the chart of accounts from the external system. accountType filters by category (e.g. 'expense', 'asset').""" @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 getBookingByExternalId(self, config: Dict[str, Any], externalId: str) -> SyncResult: """Fetch the booking in the external system by its external ID (UUID). success=True: record exists. success=False: not found or error (e.g. deleted in Buha). Override in connectors that support exact lookup; default = not supported.""" return SyncResult(success=False, errorMessage="Lookup by external ID not supported by this connector") async def isBookingSynced(self, config: Dict[str, Any], booking: AccountingBooking) -> SyncResult: """Check with the external system if this booking already exists. success=True: booking exists in external system (do not push again). success=False: not found or error (allow push). Default: success=True (trust local sync record; override in connectors that can verify via API, e.g. RMA).""" return SyncResult(success=True) 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 getJournalEntries(self, config: Dict[str, Any], dateFrom: Optional[str] = None, dateTo: Optional[str] = None, accountNumbers: Optional[List[str]] = None) -> List[Dict[str, Any]]: """Read journal entries from the external system. Each entry should contain: - externalId, bookingDate, reference, description, currency, totalAmount - lines: list of {accountNumber, debitAmount, creditAmount, currency, taxCode, costCenter, description} accountNumbers: pre-fetched account numbers (avoids redundant API call). Override in connectors that support it.""" return [] async def uploadDocument( self, config: Dict[str, Any], fileName: str, fileContent: bytes, mimeType: str = "application/pdf", comment: Optional[str] = None, ) -> SyncResult: """Upload a document/receipt (e.g. beleg). comment can link to booking reference. Override in connectors that support it.""" return SyncResult(success=False, errorMessage="Document upload not supported by this connector")