# Copyright (c) 2025 Patrick Motsch # All rights reserved. """Abacus ERP accounting connector. API docs: https://downloads.abacus.ch/fileadmin/ablage/abaconnect/htmlfiles/docs/restapi/abacus_rest_api.html Auth: OAuth 2.0 Client Credentials (Service User). Each Abacus instance has its own host URL; there is no central cloud endpoint. Entity API uses OData V4 format. Account balances: Abacus exposes an ``AccountBalances`` entity (per fiscal year), but its availability depends on the customer's Abacus license / Profile and is NOT guaranteed for all instances. The robust default is therefore to aggregate balances locally from ``GeneralJournalEntries`` (always present). If a future iteration confirms the entity for a specific instance, ``getAccountBalances`` can be extended to prefer that source via a config flag (e.g. ``useAccountBalancesEntity: true``). """ import base64 import calendar import logging import time 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__) 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 heuristic: 1xxx + 2xxx -> balance sheet (cumulative); 3xxx..9xxx -> income statement (reset per fiscal year). """ a = (accountNumber or "").strip() if not a or not a[0].isdigit(): return False return a[0] not in ("1", "2") class AccountingConnectorAbacus(BaseAccountingConnector): def __init__(self): self._tokenCache: Dict[str, Dict[str, Any]] = {} def getConnectorType(self) -> str: return "abacus" def getConnectorLabel(self) -> str: return "Abacus ERP" def getRequiredConfigFields(self) -> List[ConnectorConfigField]: return [ ConnectorConfigField( key="apiBaseUrl", label=t("API Base URL"), fieldType="text", secret=False, placeholder="e.g. https://abacus.meinefirma.ch/api/entity/v1/", ), ConnectorConfigField( key="clientName", label=t("Mandantenname"), fieldType="text", secret=False, placeholder="e.g. 7777", ), ConnectorConfigField( key="clientId", label=t("Client-ID"), fieldType="text", secret=False, ), ConnectorConfigField( key="clientSecret", label=t("Client-Secret"), fieldType="password", secret=True, ), ] def _buildBaseUrl(self, config: Dict[str, Any]) -> str: apiBaseUrl = str(config.get("apiBaseUrl") or "").strip() if not apiBaseUrl: raise ValueError("Missing required config: apiBaseUrl") if not apiBaseUrl.startswith("http"): apiBaseUrl = f"https://{apiBaseUrl}" return apiBaseUrl.rstrip("/") def _buildAuthBaseUrl(self, config: Dict[str, Any]) -> str: apiBaseUrl = str(config.get("apiBaseUrl") or "").strip() if not apiBaseUrl: raise ValueError("Missing required config: apiBaseUrl") if not apiBaseUrl.startswith("http"): apiBaseUrl = f"https://{apiBaseUrl}" apiBaseUrl = apiBaseUrl.rstrip("/") if "/api/entity/v1" in apiBaseUrl: return apiBaseUrl.split("/api/entity/v1", 1)[0] if "/api/" in apiBaseUrl: return apiBaseUrl.split("/api/", 1)[0] return apiBaseUrl async def _getAccessToken(self, config: Dict[str, Any]) -> Optional[str]: """Obtain an OAuth access token using client_credentials grant. Tokens are cached and refreshed when expired (default 600s). """ cacheKey = f"{config.get('apiBaseUrl')}_{config.get('clientName')}_{config.get('clientId')}" cached = self._tokenCache.get(cacheKey) if cached and cached.get("expiresAt", 0) > time.time() + 30: return cached["accessToken"] baseUrl = self._buildAuthBaseUrl(config) try: async with aiohttp.ClientSession() as session: # Step 1: discover token endpoint async with session.get(f"{baseUrl}/.well-known/openid-configuration", timeout=aiohttp.ClientTimeout(total=10)) as resp: if resp.status != 200: logger.error(f"Abacus OIDC discovery failed: HTTP {resp.status}") return None oidc = await resp.json() tokenEndpoint = oidc.get("token_endpoint") if not tokenEndpoint: logger.error("Abacus OIDC: no token_endpoint found") return None # Step 2: request token credentials = base64.b64encode(f"{config['clientId']}:{config['clientSecret']}".encode()).decode() headers = {"Authorization": f"Basic {credentials}", "Content-Type": "application/x-www-form-urlencoded"} async with session.post(tokenEndpoint, headers=headers, data="grant_type=client_credentials", timeout=aiohttp.ClientTimeout(total=15)) as tokenResp: if tokenResp.status != 200: body = await tokenResp.text() logger.error(f"Abacus token request failed: HTTP {tokenResp.status}: {body[:200]}") return None tokenData = await tokenResp.json() accessToken = tokenData.get("access_token") expiresIn = tokenData.get("expires_in", 600) self._tokenCache[cacheKey] = {"accessToken": accessToken, "expiresAt": time.time() + expiresIn} return accessToken except Exception as e: logger.error(f"Abacus token acquisition error: {e}") return None def _buildEntityUrl(self, config: Dict[str, Any], entity: str) -> str: baseUrl = self._buildBaseUrl(config) clientName = config.get("clientName") if not clientName: raise ValueError("Missing required config: clientName") return f"{baseUrl}/{clientName}/{entity}" async def _buildAuthHeaders(self, config: Dict[str, Any]) -> Optional[Dict[str, str]]: token = await self._getAccessToken(config) if not token: return None return {"Authorization": f"Bearer {token}", "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 "") clientId = str(config.get("clientId") or "") clientSecret = str(config.get("clientSecret") or "") if not apiBaseUrl or not clientName or not clientId or not clientSecret: return SyncResult( success=False, errorMessage=( f"Missing credentials: apiBaseUrl={bool(apiBaseUrl)}, " f"clientName={bool(clientName)}, clientId={bool(clientId)}, " f"clientSecret={bool(clientSecret)}" ), ) headers = await self._buildAuthHeaders(config) if not headers: return SyncResult(success=False, errorMessage="Failed to obtain access token") try: async with aiohttp.ClientSession() as session: url = self._buildEntityUrl(config, "Accounts?$top=1") async with session.get(url, headers=headers, 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 getChartOfAccounts(self, config: Dict[str, Any], accountType: Optional[str] = None) -> List[AccountingChart]: headers = await self._buildAuthHeaders(config) if not headers: return [] charts: List[AccountingChart] = [] url: Optional[str] = self._buildEntityUrl(config, "Accounts") try: async with aiohttp.ClientSession() as session: while url: async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=30)) as resp: if resp.status != 200: break data = await resp.json() for item in data.get("value", []): charts.append(AccountingChart( accountNumber=str(item.get("AccountNumber", item.get("Id", ""))), label=item.get("Name", item.get("Description", "")), accountType=item.get("AccountType", None), )) url = data.get("@odata.nextLink") except Exception as e: logger.error(f"Abacus getChartOfAccounts error: {e}") return charts async def pushBooking(self, config: Dict[str, Any], booking: AccountingBooking) -> SyncResult: headers = await self._buildAuthHeaders(config) if not headers: return SyncResult(success=False, errorMessage="Failed to obtain access token") try: lines = [] for line in booking.lines: entry: Dict[str, Any] = { "AccountId": line.accountNumber, "Text": line.description or booking.description, } if line.debitAmount > 0: entry["DebitAmount"] = line.debitAmount if line.creditAmount > 0: entry["CreditAmount"] = line.creditAmount if line.taxCode: entry["TaxCode"] = line.taxCode if line.costCenter: entry["CostCenterId"] = line.costCenter lines.append(entry) payload = { "JournalDate": booking.bookingDate, "Reference": booking.reference, "Text": booking.description, "Lines": lines, } async with aiohttp.ClientSession() as session: url = self._buildEntityUrl(config, "GeneralJournalEntries") async with session.post(url, headers=headers, json=payload, timeout=aiohttp.ClientTimeout(total=30)) as resp: body = await resp.json() if resp.content_type and "json" in resp.content_type 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: headers = await self._buildAuthHeaders(config) if not headers: return SyncResult(success=False, errorMessage="Failed to obtain access token") try: async with aiohttp.ClientSession() as session: url = self._buildEntityUrl(config, f"GeneralJournalEntries({externalId})") async with session.get(url, headers=headers, 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 GeneralJournalEntries from Abacus (OData V4, paginated).""" headers = await self._buildAuthHeaders(config) if not headers: return [] filterParts = [] if dateFrom: filterParts.append(f"JournalDate ge {dateFrom}") if dateTo: filterParts.append(f"JournalDate le {dateTo}") queryParams = "" if filterParts: queryParams = "?$filter=" + " and ".join(filterParts) entries: List[Dict[str, Any]] = [] url: Optional[str] = self._buildEntityUrl(config, f"GeneralJournalEntries{queryParams}") try: async with aiohttp.ClientSession() as session: while url: async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=60)) as resp: if resp.status != 200: break data = await resp.json() for item in data.get("value", []): lines = [] totalAmt = 0.0 for line in (item.get("Lines") or []): debit = float(line.get("DebitAmount", 0)) credit = float(line.get("CreditAmount", 0)) lines.append({ "accountNumber": str(line.get("AccountId", "")), "debitAmount": debit, "creditAmount": credit, "description": line.get("Text", ""), "taxCode": line.get("TaxCode"), "costCenter": line.get("CostCenterId"), }) totalAmt += max(debit, credit) entries.append({ "externalId": str(item.get("Id", "")), "bookingDate": str(item.get("JournalDate", "")).split("T")[0], "reference": item.get("Reference", ""), "description": item.get("Text", ""), "currency": "CHF", "totalAmount": totalAmt, "lines": lines, }) url = data.get("@odata.nextLink") except Exception as e: logger.error(f"Abacus getJournalEntries error: {e}") return entries async def getCustomers(self, config: Dict[str, Any]) -> List[Dict[str, Any]]: headers = await self._buildAuthHeaders(config) if not headers: return [] try: async with aiohttp.ClientSession() as session: url = self._buildEntityUrl(config, "Debtors") async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=30)) as resp: if resp.status != 200: return [] data = await resp.json() return data.get("value", []) except Exception as e: logger.error(f"Abacus getCustomers error: {e}") return [] async def getVendors(self, config: Dict[str, Any]) -> List[Dict[str, Any]]: headers = await self._buildAuthHeaders(config) if not headers: return [] try: async with aiohttp.ClientSession() as session: url = self._buildEntityUrl(config, "Creditors") async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=30)) as resp: if resp.status != 200: return [] data = await resp.json() return data.get("value", []) except Exception as e: logger.error(f"Abacus getVendors error: {e}") return [] async def getAccountBalances( self, config: Dict[str, Any], years: List[int], accountNumbers: Optional[List[str]] = None, ) -> List[AccountingPeriodBalance]: """Aggregate account balances from ``GeneralJournalEntries`` (OData V4). Strategy: 1. Page through ``GET GeneralJournalEntries?$filter=JournalDate le YYYY-12-31`` until ``@odata.nextLink`` is exhausted. Including ALL prior years is required to compute the carry-over for balance-sheet accounts. 2. Per (account, year, month) accumulate ``DebitAmount``/``CreditAmount`` from ``Lines``. 3. Income-statement accounts (3xxx-9xxx) reset to 0 per fiscal year; balance-sheet accounts (1xxx-2xxx) carry their cumulative balance. Optional optimization (not yet active): if the customer's Abacus instance ships the ``AccountBalances`` OData entity, it can return authoritative period balances directly. Detect via a probe GET on ``AccountBalances?$top=1`` and prefer that source. This is intentionally deferred until we hit a customer where the entity is available -- the local aggregation is always-correct fallback. """ 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 headers = await self._buildAuthHeaders(config) if not headers: logger.warning("Abacus getAccountBalances: no access token, skipping") return [] rawEntries = await self._fetchAllJournalEntries(config, headers, dateTo=f"{maxYear}-12-31") movements: Dict[Tuple[str, int, int], Dict[str, float]] = {} seenAccounts: set = set() for entry in rawEntries: dateRaw = str(entry.get("JournalDate") or "")[:10] if len(dateRaw) < 7: continue try: year = int(dateRaw[:4]) month = int(dateRaw[5:7]) except ValueError: continue for line in (entry.get("Lines") or []): accNo = str(line.get("AccountId") or "").strip() if not accNo: continue seenAccounts.add(accNo) try: debit = float(line.get("DebitAmount") or 0) credit = float(line.get("CreditAmount") or 0) except (TypeError, ValueError): continue if debit == 0 and credit == 0: continue bucket = movements.setdefault((accNo, year, month), {"debit": 0.0, "credit": 0.0}) bucket["debit"] += debit bucket["credit"] += credit results: List[AccountingPeriodBalance] = [] for accNo in sorted(seenAccounts): 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: yearOpening = 0.0 if isER else 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( "Abacus getAccountBalances: %s rows from %s journal entries (years=%s)", len(results), len(rawEntries), sortedYears, ) return results async def _fetchAllJournalEntries( self, config: Dict[str, Any], headers: Dict[str, str], dateTo: str, ) -> List[Dict[str, Any]]: """Page through ``GeneralJournalEntries`` (OData V4) following ``@odata.nextLink``. We filter ``JournalDate le dateTo`` to bound the result, but include ALL prior years (no lower bound) so cumulative balance-sheet carry-over is correct. """ results: List[Dict[str, Any]] = [] baseUrl = self._buildEntityUrl(config, f"GeneralJournalEntries?$filter=JournalDate le {dateTo}") nextUrl: Optional[str] = baseUrl async with aiohttp.ClientSession() as session: while nextUrl: try: async with session.get(nextUrl, headers=headers, timeout=aiohttp.ClientTimeout(total=60)) as resp: if resp.status != 200: body = await resp.text() logger.warning("Abacus GeneralJournalEntries HTTP %s: %s", resp.status, body[:200]) break data = await resp.json() except Exception as ex: logger.warning("Abacus GeneralJournalEntries request failed: %s", ex) break page = data.get("value") or [] if not isinstance(page, list): break results.extend(page) nextUrl = data.get("@odata.nextLink") return results