gateway/modules/features/trustee/accounting/accountingConnectorBase.py
2026-04-26 08:31:35 +02:00

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")