183 lines
7.6 KiB
Python
183 lines
7.6 KiB
Python
# 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 AccountingPeriodBalance(BaseModel):
|
|
"""Balance snapshot for one account in one period.
|
|
|
|
Mirrors the `TrusteeDataAccountBalance` table 1:1 so
|
|
`accountingDataSync._persistBalances` can persist connector output without
|
|
re-mapping. `closingBalance` is always the *cumulative* balance at the end
|
|
of the period (NOT the period's net movement). `periodMonth=0` denotes the
|
|
annual bucket (closing balance per fiscal year-end).
|
|
"""
|
|
accountNumber: str
|
|
periodYear: int
|
|
periodMonth: int = 0
|
|
openingBalance: float = 0.0
|
|
debitTotal: float = 0.0
|
|
creditTotal: float = 0.0
|
|
closingBalance: float = 0.0
|
|
currency: str = "CHF"
|
|
asOfDate: 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 getAccountBalances(
|
|
self,
|
|
config: Dict[str, Any],
|
|
years: List[int],
|
|
accountNumbers: Optional[List[str]] = None,
|
|
) -> List[AccountingPeriodBalance]:
|
|
"""Read closing balances per account and period from the external system.
|
|
|
|
Contract:
|
|
- One row per (accountNumber, periodYear, periodMonth).
|
|
- `periodMonth=0` => annual bucket (closing balance per fiscal year-end).
|
|
- `periodMonth=1..12` => closing balance per end of that calendar month.
|
|
- `closingBalance` MUST be the *cumulative* balance at period end,
|
|
including all prior-year carry-over and yearend bookings -- NOT the
|
|
period's net movement.
|
|
- `openingBalance` MUST be the cumulative balance at period start
|
|
(= previous period's closingBalance).
|
|
|
|
Default returns []; `AccountingDataSync` will then fall back to a
|
|
local cumulative aggregation from journal lines. Override in
|
|
connectors that can fetch authoritative balances from the source
|
|
system (e.g. RMA `/gl/saldo`).
|
|
"""
|
|
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")
|