342 lines
15 KiB
Python
342 lines
15 KiB
Python
# 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.
|
|
"""
|
|
|
|
import base64
|
|
import logging
|
|
import time
|
|
from typing import List, Dict, Any, Optional
|
|
|
|
import aiohttp
|
|
|
|
from ..accountingConnectorBase import (
|
|
BaseAccountingConnector,
|
|
AccountingBooking,
|
|
AccountingChart,
|
|
ConnectorConfigField,
|
|
SyncResult,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class AccountingConnectorAbacus(BaseAccountingConnector):
|
|
|
|
def __init__(self):
|
|
self._tokenCache: Dict[str, Dict[str, Any]] = {}
|
|
|
|
def getConnectorType(self) -> str:
|
|
return "abacus"
|
|
|
|
def getConnectorLabel(self) -> Dict[str, str]:
|
|
return {"en": "Abacus ERP", "de": "Abacus ERP", "fr": "Abacus ERP"}
|
|
|
|
def getRequiredConfigFields(self) -> List[ConnectorConfigField]:
|
|
return [
|
|
ConnectorConfigField(
|
|
key="apiBaseUrl",
|
|
label={"en": "API Base URL", "de": "API Base URL", "fr": "URL de base API"},
|
|
fieldType="text",
|
|
secret=False,
|
|
placeholder="e.g. https://abacus.meinefirma.ch/api/entity/v1/",
|
|
),
|
|
ConnectorConfigField(
|
|
key="clientName",
|
|
label={"en": "Client Name", "de": "Mandantenname", "fr": "Nom du client"},
|
|
fieldType="text",
|
|
secret=False,
|
|
placeholder="e.g. 7777",
|
|
),
|
|
ConnectorConfigField(
|
|
key="clientId",
|
|
label={"en": "Client ID", "de": "Client-ID", "fr": "ID Client"},
|
|
fieldType="text",
|
|
secret=False,
|
|
),
|
|
ConnectorConfigField(
|
|
key="clientSecret",
|
|
label={"en": "Client Secret", "de": "Client-Secret", "fr": "Secret Client"},
|
|
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 []
|