435 lines
19 KiB
Python
435 lines
19 KiB
Python
# Copyright (c) 2025 Patrick Motsch
|
|
# All rights reserved.
|
|
"""Bexio accounting connector.
|
|
|
|
API docs: https://docs.bexio.com/
|
|
Auth: Personal Access Token (PAT) as Bearer token.
|
|
Base URL: https://api.bexio.com/
|
|
Note: Bexio uses internal account IDs (int), not account numbers.
|
|
The connector caches the chart of accounts to resolve accountNumber -> account_id.
|
|
|
|
Account balances:
|
|
Bexio does NOT expose a dedicated saldo endpoint (no equivalent to RMA's
|
|
``/gl/saldo``). ``getAccountBalances`` therefore aggregates balances
|
|
locally by paginating ``GET /3.0/accounting/journal`` (max 2000 rows per
|
|
page) and computing cumulative balances per (account, period). Income-
|
|
statement accounts (3xxx-9xxx in the Swiss KMU-Kontenrahmen) are reset
|
|
at the start of each fiscal year; balance-sheet accounts (1xxx-2xxx)
|
|
carry their cumulative balance across years.
|
|
"""
|
|
|
|
import calendar
|
|
import logging
|
|
from typing import List, Dict, Any, Optional, Tuple
|
|
|
|
import aiohttp
|
|
|
|
from ..accountingConnectorBase import (
|
|
BaseAccountingConnector,
|
|
AccountingBooking,
|
|
AccountingChart,
|
|
AccountingPeriodBalance,
|
|
ConnectorConfigField,
|
|
SyncResult,
|
|
)
|
|
from modules.shared.i18nRegistry import t
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_DEFAULT_API_BASE_URL = "https://api.bexio.com/"
|
|
_JOURNAL_PAGE_SIZE = 2000
|
|
|
|
|
|
def _formatLastDayOfMonth(year: int, month: int) -> str:
|
|
lastDay = calendar.monthrange(year, month)[1]
|
|
return f"{year:04d}-{month:02d}-{lastDay:02d}"
|
|
|
|
|
|
def _isIncomeStatementAccount(accountNumber: str) -> bool:
|
|
"""Swiss KMU-Kontenrahmen: 1xxx Aktiven + 2xxx Passiven -> balance sheet
|
|
(cumulative balance carried across years); 3xxx..9xxx -> income statement
|
|
(reset to 0 at fiscal-year start).
|
|
"""
|
|
a = (accountNumber or "").strip()
|
|
if not a or not a[0].isdigit():
|
|
return False
|
|
return a[0] not in ("1", "2")
|
|
|
|
|
|
class AccountingConnectorBexio(BaseAccountingConnector):
|
|
|
|
def __init__(self):
|
|
self._chartCache: Dict[str, List[Dict[str, Any]]] = {}
|
|
|
|
def getConnectorType(self) -> str:
|
|
return "bexio"
|
|
|
|
def getConnectorLabel(self) -> str:
|
|
return "Bexio"
|
|
|
|
def getRequiredConfigFields(self) -> List[ConnectorConfigField]:
|
|
return [
|
|
ConnectorConfigField(
|
|
key="apiBaseUrl",
|
|
label=t("API Base URL"),
|
|
fieldType="text",
|
|
secret=False,
|
|
placeholder="https://api.bexio.com/",
|
|
),
|
|
ConnectorConfigField(
|
|
key="clientName",
|
|
label=t("Mandantenname"),
|
|
fieldType="text",
|
|
secret=False,
|
|
placeholder="e.g. poweronag",
|
|
),
|
|
ConnectorConfigField(
|
|
key="accessToken",
|
|
label=t("Persönlicher Zugriffstoken"),
|
|
fieldType="password",
|
|
secret=True,
|
|
placeholder="PAT from developer.bexio.com",
|
|
),
|
|
]
|
|
|
|
def _buildUrl(self, config: Dict[str, Any], resource: str) -> str:
|
|
apiBaseUrl = str(config.get("apiBaseUrl") or "").strip()
|
|
if not apiBaseUrl:
|
|
raise ValueError("Missing required config: apiBaseUrl")
|
|
apiBaseUrl = apiBaseUrl.rstrip("/")
|
|
resourcePath = resource.lstrip("/")
|
|
return f"{apiBaseUrl}/{resourcePath}"
|
|
|
|
def _buildHeaders(self, config: Dict[str, Any]) -> Dict[str, str]:
|
|
return {
|
|
"Authorization": f"Bearer {config['accessToken']}",
|
|
"Accept": "application/json",
|
|
"Content-Type": "application/json",
|
|
}
|
|
|
|
async def testConnection(self, config: Dict[str, Any]) -> SyncResult:
|
|
apiBaseUrl = str(config.get("apiBaseUrl") or "")
|
|
clientName = str(config.get("clientName") or "")
|
|
accessToken = str(config.get("accessToken") or "")
|
|
if not apiBaseUrl or not clientName or not accessToken:
|
|
return SyncResult(
|
|
success=False,
|
|
errorMessage=(
|
|
f"Missing credentials: apiBaseUrl={bool(apiBaseUrl)}, "
|
|
f"clientName={bool(clientName)}, accessToken={bool(accessToken)}"
|
|
),
|
|
)
|
|
try:
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.get(self._buildUrl(config, "3.0/users/me"), headers=self._buildHeaders(config), timeout=aiohttp.ClientTimeout(total=15)) as resp:
|
|
if resp.status == 200:
|
|
return SyncResult(success=True)
|
|
body = await resp.text()
|
|
return SyncResult(success=False, errorMessage=f"HTTP {resp.status}: {body[:200]}")
|
|
except Exception as e:
|
|
return SyncResult(success=False, errorMessage=str(e))
|
|
|
|
async def _loadRawAccounts(self, config: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
"""Load raw account list and cache it for accountNumber -> id mapping."""
|
|
cacheKey = config.get("accessToken", "")[:16]
|
|
if cacheKey in self._chartCache:
|
|
return self._chartCache[cacheKey]
|
|
|
|
try:
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.get(self._buildUrl(config, "2.0/accounts"), headers=self._buildHeaders(config), timeout=aiohttp.ClientTimeout(total=30)) as resp:
|
|
if resp.status != 200:
|
|
return []
|
|
accounts = await resp.json()
|
|
self._chartCache[cacheKey] = accounts
|
|
return accounts
|
|
except Exception as e:
|
|
logger.error(f"Bexio loadRawAccounts error: {e}")
|
|
return []
|
|
|
|
def _resolveAccountId(self, accounts: List[Dict[str, Any]], accountNumber: str) -> Optional[int]:
|
|
"""Resolve an account number string to a Bexio internal account_id."""
|
|
for acc in accounts:
|
|
if str(acc.get("account_no", "")) == accountNumber:
|
|
return acc.get("id")
|
|
return None
|
|
|
|
async def getChartOfAccounts(self, config: Dict[str, Any], accountType: Optional[str] = None) -> List[AccountingChart]:
|
|
accounts = await self._loadRawAccounts(config)
|
|
return [
|
|
AccountingChart(
|
|
accountNumber=str(acc.get("account_no", "")),
|
|
label=acc.get("name", ""),
|
|
accountType=acc.get("account_type", None),
|
|
)
|
|
for acc in accounts
|
|
]
|
|
|
|
async def pushBooking(self, config: Dict[str, Any], booking: AccountingBooking) -> SyncResult:
|
|
"""Push a manual entry to Bexio.
|
|
|
|
Bexio requires account_id (int) rather than account numbers, so we resolve
|
|
via the cached chart of accounts.
|
|
"""
|
|
try:
|
|
accounts = await self._loadRawAccounts(config)
|
|
|
|
entries = []
|
|
for line in booking.lines:
|
|
debitAccId = self._resolveAccountId(accounts, line.accountNumber) if line.debitAmount > 0 else None
|
|
creditAccId = self._resolveAccountId(accounts, line.accountNumber) if line.creditAmount > 0 else None
|
|
amount = line.debitAmount if line.debitAmount > 0 else line.creditAmount
|
|
|
|
if debitAccId is None and creditAccId is None:
|
|
return SyncResult(success=False, errorMessage=f"Account {line.accountNumber} not found in Bexio chart")
|
|
|
|
entry: Dict[str, Any] = {"amount": str(amount), "description": line.description or booking.description}
|
|
if debitAccId:
|
|
entry["debit_account_id"] = debitAccId
|
|
if creditAccId:
|
|
entry["credit_account_id"] = creditAccId
|
|
if line.taxCode:
|
|
entry["tax_id"] = line.taxCode
|
|
entries.append(entry)
|
|
|
|
entryType = "manual_single_entry" if len(entries) == 1 else "manual_group_entry"
|
|
payload = {
|
|
"date": booking.bookingDate,
|
|
"reference_nr": booking.reference,
|
|
"type": entryType,
|
|
"entries": entries,
|
|
}
|
|
|
|
async with aiohttp.ClientSession() as session:
|
|
url = self._buildUrl(config, "3.0/accounting/manual-entries")
|
|
async with session.post(url, headers=self._buildHeaders(config), json=payload, timeout=aiohttp.ClientTimeout(total=30)) as resp:
|
|
body = await resp.json() if resp.content_type == "application/json" else {"raw": await resp.text()}
|
|
if resp.status in (200, 201):
|
|
externalId = str(body.get("id", "")) if isinstance(body, dict) else None
|
|
return SyncResult(success=True, externalId=externalId, rawResponse=body)
|
|
return SyncResult(success=False, errorMessage=f"HTTP {resp.status}", rawResponse=body)
|
|
except Exception as e:
|
|
return SyncResult(success=False, errorMessage=str(e))
|
|
|
|
async def getBookingStatus(self, config: Dict[str, Any], externalId: str) -> SyncResult:
|
|
try:
|
|
async with aiohttp.ClientSession() as session:
|
|
url = self._buildUrl(config, f"3.0/accounting/manual-entries/{externalId}")
|
|
async with session.get(url, headers=self._buildHeaders(config), timeout=aiohttp.ClientTimeout(total=15)) as resp:
|
|
if resp.status == 200:
|
|
return SyncResult(success=True, externalId=externalId)
|
|
return SyncResult(success=False, errorMessage=f"HTTP {resp.status}")
|
|
except Exception as e:
|
|
return SyncResult(success=False, errorMessage=str(e))
|
|
|
|
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 manual entries from Bexio. API: GET 3.0/accounting/manual-entries"""
|
|
try:
|
|
accounts = await self._loadRawAccounts(config)
|
|
accMap = {acc.get("id"): str(acc.get("account_no", "")) for acc in accounts}
|
|
|
|
async with aiohttp.ClientSession() as session:
|
|
url = self._buildUrl(config, "3.0/accounting/manual-entries")
|
|
params: Dict[str, str] = {}
|
|
if dateFrom:
|
|
params["date_from"] = dateFrom
|
|
if dateTo:
|
|
params["date_to"] = dateTo
|
|
async with session.get(url, headers=self._buildHeaders(config), params=params, timeout=aiohttp.ClientTimeout(total=60)) as resp:
|
|
if resp.status != 200:
|
|
logger.error(f"Bexio getJournalEntries failed: HTTP {resp.status}")
|
|
return []
|
|
items = await resp.json()
|
|
|
|
entries = []
|
|
for item in (items if isinstance(items, list) else []):
|
|
lines = []
|
|
totalAmt = 0.0
|
|
for e in (item.get("entries") or []):
|
|
amt = float(e.get("amount", 0))
|
|
debitAccId = e.get("debit_account_id")
|
|
creditAccId = e.get("credit_account_id")
|
|
lines.append({
|
|
"accountNumber": accMap.get(debitAccId, str(debitAccId or "")),
|
|
"debitAmount": amt,
|
|
"creditAmount": 0.0,
|
|
"description": e.get("description", ""),
|
|
"taxCode": str(e.get("tax_id", "")) if e.get("tax_id") else None,
|
|
})
|
|
if creditAccId and creditAccId != debitAccId:
|
|
lines.append({
|
|
"accountNumber": accMap.get(creditAccId, str(creditAccId or "")),
|
|
"debitAmount": 0.0,
|
|
"creditAmount": amt,
|
|
"description": e.get("description", ""),
|
|
})
|
|
totalAmt += amt
|
|
entries.append({
|
|
"externalId": str(item.get("id", "")),
|
|
"bookingDate": item.get("date", ""),
|
|
"reference": item.get("reference_nr", ""),
|
|
"description": item.get("text", ""),
|
|
"currency": "CHF",
|
|
"totalAmount": totalAmt,
|
|
"lines": lines,
|
|
})
|
|
return entries
|
|
except Exception as e:
|
|
logger.error(f"Bexio getJournalEntries error: {e}")
|
|
return []
|
|
|
|
async def getCustomers(self, config: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
try:
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.get(self._buildUrl(config, "2.0/contact"), headers=self._buildHeaders(config), timeout=aiohttp.ClientTimeout(total=30)) as resp:
|
|
if resp.status != 200:
|
|
return []
|
|
return await resp.json()
|
|
except Exception as e:
|
|
logger.error(f"Bexio getCustomers error: {e}")
|
|
return []
|
|
|
|
async def getAccountBalances(
|
|
self,
|
|
config: Dict[str, Any],
|
|
years: List[int],
|
|
accountNumbers: Optional[List[str]] = None,
|
|
) -> List[AccountingPeriodBalance]:
|
|
"""Aggregate account balances locally from ``/3.0/accounting/journal``.
|
|
|
|
Bexio offers no per-account saldo endpoint, so we paginate the full
|
|
journal up to the latest requested fiscal year-end and compute
|
|
opening / debit / credit / closing per (account, period). For balance-
|
|
sheet accounts the cumulative carry-over from prior years is included;
|
|
for income-statement accounts the balance is reset at the start of
|
|
every requested fiscal year (per Swiss accounting principles).
|
|
"""
|
|
if not years:
|
|
return []
|
|
sortedYears = sorted({int(y) for y in years if y})
|
|
minYear = sortedYears[0]
|
|
maxYear = sortedYears[-1]
|
|
accountNumbersSet = set(accountNumbers) if accountNumbers else None
|
|
|
|
accounts = await self._loadRawAccounts(config)
|
|
accIdToNumber: Dict[int, str] = {acc.get("id"): str(acc.get("account_no", "")) for acc in accounts if acc.get("id") is not None and acc.get("account_no") is not None}
|
|
if not accIdToNumber:
|
|
logger.warning("Bexio getAccountBalances: chart of accounts is empty -- cannot derive balances")
|
|
return []
|
|
|
|
rawEntries = await self._fetchAllJournalRows(config, dateTo=f"{maxYear}-12-31")
|
|
|
|
movements: Dict[Tuple[str, int, int], Dict[str, float]] = {}
|
|
for e in rawEntries:
|
|
dateRaw = str(e.get("date") or "")[:10]
|
|
if len(dateRaw) < 7:
|
|
continue
|
|
try:
|
|
year = int(dateRaw[:4])
|
|
month = int(dateRaw[5:7])
|
|
except ValueError:
|
|
continue
|
|
try:
|
|
amount = float(e.get("amount") or 0)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if amount == 0:
|
|
continue
|
|
debitAcc = accIdToNumber.get(e.get("debit_account_id"))
|
|
creditAcc = accIdToNumber.get(e.get("credit_account_id"))
|
|
if debitAcc:
|
|
bucket = movements.setdefault((debitAcc, year, month), {"debit": 0.0, "credit": 0.0})
|
|
bucket["debit"] += amount
|
|
if creditAcc:
|
|
bucket = movements.setdefault((creditAcc, year, month), {"debit": 0.0, "credit": 0.0})
|
|
bucket["credit"] += amount
|
|
|
|
accountsByNumber = sorted({n for n in accIdToNumber.values() if n})
|
|
results: List[AccountingPeriodBalance] = []
|
|
|
|
for accNo in accountsByNumber:
|
|
if accountNumbersSet is not None and accNo not in accountNumbersSet:
|
|
continue
|
|
isER = _isIncomeStatementAccount(accNo)
|
|
|
|
preMinYearBalance = 0.0
|
|
if not isER:
|
|
for (a, yr, _mo), m in movements.items():
|
|
if a == accNo and yr < minYear:
|
|
preMinYearBalance += m["debit"] - m["credit"]
|
|
|
|
cumulativeOpeningOfYear = preMinYearBalance
|
|
for year in sortedYears:
|
|
if isER:
|
|
yearOpening = 0.0
|
|
else:
|
|
yearOpening = cumulativeOpeningOfYear
|
|
|
|
running = yearOpening
|
|
yearDebit = 0.0
|
|
yearCredit = 0.0
|
|
for month in range(1, 13):
|
|
opening = running
|
|
mov = movements.get((accNo, year, month), {"debit": 0.0, "credit": 0.0})
|
|
running = opening + mov["debit"] - mov["credit"]
|
|
yearDebit += mov["debit"]
|
|
yearCredit += mov["credit"]
|
|
results.append(AccountingPeriodBalance(
|
|
accountNumber=accNo,
|
|
periodYear=year,
|
|
periodMonth=month,
|
|
openingBalance=round(opening, 2),
|
|
debitTotal=round(mov["debit"], 2),
|
|
creditTotal=round(mov["credit"], 2),
|
|
closingBalance=round(running, 2),
|
|
currency="CHF",
|
|
asOfDate=_formatLastDayOfMonth(year, month),
|
|
))
|
|
|
|
results.append(AccountingPeriodBalance(
|
|
accountNumber=accNo,
|
|
periodYear=year,
|
|
periodMonth=0,
|
|
openingBalance=round(yearOpening, 2),
|
|
debitTotal=round(yearDebit, 2),
|
|
creditTotal=round(yearCredit, 2),
|
|
closingBalance=round(running, 2),
|
|
currency="CHF",
|
|
asOfDate=f"{year}-12-31",
|
|
))
|
|
|
|
cumulativeOpeningOfYear = running
|
|
|
|
logger.info("Bexio getAccountBalances: %s rows from %s journal entries (years=%s)", len(results), len(rawEntries), sortedYears)
|
|
return results
|
|
|
|
async def _fetchAllJournalRows(self, config: Dict[str, Any], dateTo: str) -> List[Dict[str, Any]]:
|
|
"""Paginate ``GET /3.0/accounting/journal?to=YYYY-12-31`` and return all rows.
|
|
|
|
Bexio caps page size at 2000; we fetch until a short page is returned.
|
|
Failures abort early (returning whatever rows were collected) -- the
|
|
caller logs the row count, so partial data is visible.
|
|
"""
|
|
rows: List[Dict[str, Any]] = []
|
|
offset = 0
|
|
url = self._buildUrl(config, "3.0/accounting/journal")
|
|
async with aiohttp.ClientSession() as session:
|
|
while True:
|
|
params = {"to": dateTo, "limit": str(_JOURNAL_PAGE_SIZE), "offset": str(offset)}
|
|
try:
|
|
async with session.get(url, headers=self._buildHeaders(config), params=params, timeout=aiohttp.ClientTimeout(total=60)) as resp:
|
|
if resp.status != 200:
|
|
body = await resp.text()
|
|
logger.warning("Bexio /accounting/journal HTTP %s offset=%s: %s", resp.status, offset, body[:200])
|
|
break
|
|
page = await resp.json()
|
|
except Exception as ex:
|
|
logger.warning("Bexio /accounting/journal request failed offset=%s: %s", offset, ex)
|
|
break
|
|
if not isinstance(page, list) or not page:
|
|
break
|
|
rows.extend(page)
|
|
if len(page) < _JOURNAL_PAGE_SIZE:
|
|
break
|
|
offset += _JOURNAL_PAGE_SIZE
|
|
return rows
|