# 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))