# 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