gateway/modules/features/trustee/accounting/connectors/accountingConnectorRma.py
2026-02-21 00:56:53 +01:00

204 lines
9.4 KiB
Python

# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""Run My Accounts (Infoniqa) accounting connector.
API docs: https://runmyaccountsag.github.io/runmyaccounts-rest-api/
Auth: Static API key via X-RMA-KEY header.
Base URL: https://service.runmyaccounts.com/api/latest/clients/{clientName}/
"""
import logging
from typing import List, Dict, Any
import aiohttp
from ..accountingConnectorBase import (
BaseAccountingConnector,
AccountingBooking,
AccountingChart,
ConnectorConfigField,
SyncResult,
)
logger = logging.getLogger(__name__)
_BASE_URL = "https://service.runmyaccounts.com/api/latest/clients"
class AccountingConnectorRma(BaseAccountingConnector):
def getConnectorType(self) -> str:
return "rma"
def getConnectorLabel(self) -> Dict[str, str]:
return {"en": "Run My Accounts", "de": "Run My Accounts", "fr": "Run My Accounts"}
def getRequiredConfigFields(self) -> List[ConnectorConfigField]:
return [
ConnectorConfigField(
key="clientName",
label={"en": "Client Name", "de": "Mandantenname", "fr": "Nom du client"},
fieldType="text",
secret=False,
placeholder="e.g. meinefirma",
),
ConnectorConfigField(
key="apiKey",
label={"en": "API Key", "de": "API-Schlüssel", "fr": "Clé API"},
fieldType="password",
secret=True,
),
]
def _buildUrl(self, config: Dict[str, Any], resource: str) -> str:
clientName = config.get("clientName", "")
return f"{_BASE_URL}/{clientName}/{resource}"
def _buildHeaders(self, config: Dict[str, Any]) -> Dict[str, str]:
"""PAT must not be in query params; RMA expects Authorization header."""
apiKey = config.get("apiKey", "")
return {
"Authorization": f"Bearer {apiKey}",
"Accept": "application/json",
"Content-Type": "application/json",
}
async def testConnection(self, config: Dict[str, Any]) -> SyncResult:
clientName = config.get("clientName", "")
apiKey = config.get("apiKey", "")
if not clientName or not apiKey:
return SyncResult(success=False, errorMessage=f"Missing credentials: clientName={bool(clientName)}, apiKey={bool(apiKey)}")
url = self._buildUrl(config, "customers")
headers = self._buildHeaders(config)
try:
async with aiohttp.ClientSession() as session:
async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=15)) as resp:
if resp.status == 200:
logger.info("RMA connection successful")
return SyncResult(success=True)
body = await resp.text()
return SyncResult(success=False, errorMessage=f"HTTP {resp.status}: {body[:300]}")
except Exception as e:
return SyncResult(success=False, errorMessage=str(e))
async def getChartOfAccounts(self, config: Dict[str, Any]) -> List[AccountingChart]:
try:
async with aiohttp.ClientSession() as session:
url = self._buildUrl(config, "charts")
async with session.get(url, headers=self._buildHeaders(config), timeout=aiohttp.ClientTimeout(total=30)) as resp:
if resp.status != 200:
logger.error(f"RMA charts failed: HTTP {resp.status}")
return []
data = await resp.json()
charts = []
items = data if isinstance(data, list) else data.get("chart", data.get("row", []))
for item in items:
if isinstance(item, dict):
accNo = item.get("accno", item.get("account_number", ""))
label = item.get("description", item.get("label", ""))
charts.append(AccountingChart(accountNumber=str(accNo), label=str(label)))
return charts
except Exception as e:
logger.error(f"RMA getChartOfAccounts error: {e}")
return []
async def pushBooking(self, config: Dict[str, Any], booking: AccountingBooking) -> SyncResult:
"""Push a GL batch booking to RMA."""
try:
entries = []
for line in booking.lines:
entry = {
"accno": line.accountNumber,
"transdate": booking.bookingDate,
"reference": booking.reference,
"description": line.description or booking.description,
}
if line.debitAmount > 0:
entry["debit"] = line.debitAmount
if line.creditAmount > 0:
entry["credit"] = line.creditAmount
if line.taxCode:
entry["tax_code"] = line.taxCode
entries.append(entry)
payload = {"gl_batch": {"entry": entries}}
async with aiohttp.ClientSession() as session:
url = self._buildUrl(config, "gl")
async with session.post(url, headers=self._buildHeaders(config), json=payload, timeout=aiohttp.ClientTimeout(total=30)) as resp:
body = await resp.text()
if resp.status in (200, 201, 204):
return SyncResult(success=True, rawResponse={"status": resp.status, "body": body[:500]})
return SyncResult(success=False, errorMessage=f"HTTP {resp.status}: {body[:300]}")
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"charts/{externalId}/transactions")
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 pushInvoice(self, config: Dict[str, Any], invoice: Dict[str, Any]) -> SyncResult:
try:
async with aiohttp.ClientSession() as session:
url = self._buildUrl(config, "invoices")
async with session.post(url, headers=self._buildHeaders(config), json=invoice, timeout=aiohttp.ClientTimeout(total=30)) as resp:
body = await resp.text()
if resp.status in (200, 201):
return SyncResult(success=True, rawResponse={"body": body[:500]})
return SyncResult(success=False, errorMessage=f"HTTP {resp.status}: {body[:300]}")
except Exception as e:
return SyncResult(success=False, errorMessage=str(e))
async def getCustomers(self, config: Dict[str, Any]) -> List[Dict[str, Any]]:
try:
async with aiohttp.ClientSession() as session:
url = self._buildUrl(config, "customers")
async with session.get(url, headers=self._buildHeaders(config), timeout=aiohttp.ClientTimeout(total=30)) as resp:
if resp.status != 200:
return []
data = await resp.json()
return data if isinstance(data, list) else data.get("customer", [])
except Exception as e:
logger.error(f"RMA getCustomers error: {e}")
return []
async def getVendors(self, config: Dict[str, Any]) -> List[Dict[str, Any]]:
try:
async with aiohttp.ClientSession() as session:
url = self._buildUrl(config, "vendors")
async with session.get(url, headers=self._buildHeaders(config), timeout=aiohttp.ClientTimeout(total=30)) as resp:
if resp.status != 200:
return []
data = await resp.json()
return data if isinstance(data, list) else data.get("vendor", [])
except Exception as e:
logger.error(f"RMA getVendors error: {e}")
return []
async def uploadDocument(self, config: Dict[str, Any], fileName: str, fileContent: bytes, mimeType: str = "application/pdf") -> SyncResult:
"""Upload a receipt via POST /belege (multipart/form-data)."""
try:
formData = aiohttp.FormData()
formData.add_field("Filedata", fileContent, filename=fileName, content_type=mimeType)
headers = self._buildHeaders(config)
headers.pop("Content-Type", None) # let aiohttp set multipart boundary
async with aiohttp.ClientSession() as session:
url = self._buildUrl(config, "belege")
async with session.post(url, headers=headers, data=formData, timeout=aiohttp.ClientTimeout(total=60)) as resp:
body = await resp.text()
if resp.status in (200, 201):
return SyncResult(success=True, rawResponse={"body": body[:500]})
return SyncResult(success=False, errorMessage=f"HTTP {resp.status}: {body[:300]}")
except Exception as e:
return SyncResult(success=False, errorMessage=str(e))