# Copyright (c) 2025 Patrick Motsch # All rights reserved. """Run My Accounts (Infoniqa) accounting connector. API docs: https://runmyaccountsag.github.io/runmyaccounts-rest-api/ Auth: PAT tokens (``pat_...``) via ``Authorization: Bearer``. Fallback for legacy API keys via ``X-RMA-KEY``. Base URL: https://service.runmyaccounts.com/api/latest/clients/{clientName}/ """ import asyncio import json import logging import re from datetime import datetime from typing import List, Dict, Any, Optional import aiohttp from ..accountingConnectorBase import ( BaseAccountingConnector, AccountingBooking, AccountingChart, ConnectorConfigField, SyncResult, ) from modules.shared.i18nRegistry import t logger = logging.getLogger(__name__) _DEFAULT_API_BASE_URL = "https://service.runmyaccounts.com/api/latest/clients/" class AccountingConnectorRma(BaseAccountingConnector): def getConnectorType(self) -> str: return "rma" def getConnectorLabel(self) -> str: return "Run My Accounts" def getRequiredConfigFields(self) -> List[ConnectorConfigField]: return [ ConnectorConfigField( key="apiBaseUrl", label=t("API Base URL"), fieldType="text", secret=False, placeholder="https://service.runmyaccounts.com/api/latest/clients/", ), ConnectorConfigField( key="clientName", label=t("Mandantenname"), fieldType="text", secret=False, placeholder="e.g. meinefirma", ), ConnectorConfigField( key="apiKey", label=t("API-Schlüssel"), fieldType="password", secret=True, ), ] 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("/") + "/" clientName = str(config.get("clientName") or "").strip() if not clientName: raise ValueError("Missing required config: clientName") return f"{apiBaseUrl}{clientName}/{resource}" def _buildHeaders(self, config: Dict[str, Any]) -> Dict[str, str]: apiKey = config.get("apiKey", "") headers = { "Accept": "application/json, application/xml, */*", "Content-Type": "application/json", } if str(apiKey).startswith("pat_"): headers["Authorization"] = f"Bearer {apiKey}" else: headers["X-RMA-KEY"] = apiKey return headers async def testConnection(self, config: Dict[str, Any]) -> SyncResult: clientName = config.get("clientName", "") apiKey = config.get("apiKey", "") apiBaseUrl = str(config.get("apiBaseUrl") or "") if not clientName or not apiKey or not apiBaseUrl: return SyncResult( success=False, errorMessage=( f"Missing credentials: apiBaseUrl={bool(apiBaseUrl)}, " f"clientName={bool(clientName)}, apiKey={bool(apiKey)}" ), ) url = self._buildUrl(config, "customers") headers = self._buildHeaders(config) authMethod = "Bearer" if str(apiKey).startswith("pat_") else "X-RMA-KEY" logger.info( "RMA testConnection: url=%s, clientName=%s, apiKey=%s..., auth=%s", url, clientName, apiKey[:6] if len(apiKey) > 6 else "***", authMethod, ) 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 with auth method: %s", authMethod) return SyncResult(success=True, rawResponse={"authMethod": authMethod}) body = await resp.text() logger.warning("RMA testConnection failed: status=%s, url=%s, body=%s", resp.status, url, body[:500]) return SyncResult(success=False, errorMessage=f"HTTP {resp.status}: {body[:300]}") except Exception as e: return SyncResult(success=False, errorMessage=str(e)) def _rmaLinkToAccountType(self, link: str) -> str: """Map RMA chart 'link' (e.g. AP_amount, AR_amount, AR_paid:AP_paid) to our accountType.""" if not link: return "" linkUpper = link.upper() if "AP_AMOUNT" in linkUpper: return "expense" if "AR_AMOUNT" in linkUpper: return "revenue" if "AR_PAID" in linkUpper or "AP_PAID" in linkUpper: return "asset" if "AR_TAX" in linkUpper or "AP_TAX" in linkUpper: return "liability" if linkUpper in ("AR", "AP"): return "asset" return link async def getChartOfAccounts(self, config: Dict[str, Any], accountType: Optional[str] = None) -> List[AccountingChart]: """RMA API 'type' filter expects RMA values (AP_amount, AR_amount, etc.), not 'expense'. Fetch full chart and filter client-side.""" 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: body = await resp.text() logger.error(f"RMA charts failed: HTTP {resp.status} - {body[:200]}") return [] data = await resp.json() charts = [] items = data if isinstance(data, list) else data.get("chart", data.get("row", [])) if not isinstance(items, list): items = [items] if isinstance(items, dict) else [] for item in items: if isinstance(item, dict): accNo = str(item.get("accno") or item.get("account_number") or item.get("number") or item.get("@accno") or "") label = str(item.get("description") or item.get("label") or item.get("@description") or "") rmaLink = item.get("link") or "" chartType = item.get("charttype") or item.get("category") or "" if not chartType and rmaLink: chartType = self._rmaLinkToAccountType(rmaLink) if not chartType and accNo: firstDigit = accNo[0] if accNo else "" chartType = { "1": "asset", "2": "liability", "3": "revenue", "4": "expense", "5": "expense", "6": "expense", "7": "expense", "8": "expense", "9": "closing", }.get(firstDigit, "") charts.append(AccountingChart(accountNumber=accNo, label=label, accountType=chartType)) 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. API expects request body = batch object (no wrapper); only non-zero amount per line.""" try: if not booking.lines: return SyncResult(success=False, errorMessage="Missing transactions in batch (no booking lines)") glTransactions = [] for line in booking.lines: t = {"accno": line.accountNumber} if line.debitAmount and line.debitAmount > 0: t["debit_amount"] = line.debitAmount if line.creditAmount and line.creditAmount > 0: t["credit_amount"] = line.creditAmount if line.description: t["memo"] = (line.description or "")[:500] glTransactions.append(t) transdate = booking.bookingDate or "" if transdate and "T" not in str(transdate): transdate = f"{transdate}T00:00:00.000+00:00" batchNumber = (booking.reference or "").strip()[:32] if not batchNumber: batchNumber = "GL-" + (booking.reference or str(id(booking)))[:24] rawDesc = (booking.description or "").strip() externalDocIds = getattr(booking, "externalDocumentIds", None) or [] externalDocLabels = getattr(booking, "externalDocumentLabels", None) or [] if externalDocIds or externalDocLabels: clientSegment = (config.get("clientId") or config.get("clientName") or "").strip() docParts = [] maxI = max(len(externalDocIds), len(externalDocLabels)) for i in range(min(maxI, 10)): label = (externalDocLabels[i] if i < len(externalDocLabels) else "Rechnung") or "Rechnung" belegId = externalDocIds[i] if i < len(externalDocIds) else None if belegId: docUrl = f"https://my.runmyaccounts.com/phoenix-core/api/clients/{clientSegment}/workflow/l0doc/file/{belegId}" docParts.append(f'{label}') else: docParts.append(label) erfDate = datetime.utcnow().strftime("%d.%m.%Y") linkSuffix = " (" + ", ".join(docParts) + ", erf. " + erfDate + ")" shortDesc = (rawDesc[:80] + "...") if len(rawDesc) > 80 else rawDesc description = (shortDesc + linkSuffix).strip()[:500] else: description = rawDesc[:500] payload = { "batch_number": batchNumber, "transdate": transdate, "description": description, "currency": "CHF", "exchangerate": 1.0, "gl_transactions": {"gl_transaction": glTransactions}, } if rawDesc and len(rawDesc) > 80: payload["notes"] = rawDesc[:2000] logger.debug("RMA pushBooking payload: batch=%s transdate=%s accounts=%s", batchNumber, transdate, [(t.get("accno"), t.get("debit_amount"), t.get("credit_amount")) for t in glTransactions]) 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): externalId = None try: data = json.loads(body) if body.strip() else {} if isinstance(data, dict): externalId = ( data.get("id") or data.get("batch_id") or data.get("entry_id") or (data.get("gl_batch") or {}).get("id") ) if externalId is not None: externalId = str(externalId).strip() if not externalId: externalId = batchNumber except Exception: externalId = batchNumber return SyncResult( success=True, externalId=externalId or batchNumber, rawResponse={"status": resp.status, "body": body[:500]}, ) errMsg = f"HTTP {resp.status}: {body[:300]}" logger.error("RMA pushBooking failed: status=%s, body=%s", resp.status, body[:500]) return SyncResult(success=False, errorMessage=errMsg) except asyncio.TimeoutError: logger.warning("RMA pushBooking timeout (30s)") return SyncResult( success=False, errorMessage="Verbindung zum Buchhaltungssystem hat zu lange gedauert (Timeout). Bitte später erneut versuchen.", ) except Exception as e: logger.exception("RMA pushBooking error") 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 getBookingByExternalId(self, config: Dict[str, Any], externalId: str) -> SyncResult: """Check if the GL batch/transaction still exists in RMA by external ID (id or batch_number).""" if not externalId or not str(externalId).strip(): return SyncResult(success=False, errorMessage="Not found") try: async with aiohttp.ClientSession() as session: url = self._buildUrl(config, f"gl/{externalId.strip()}") 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) if resp.status == 404: return SyncResult(success=False, errorMessage="Not found") body = await resp.text() return SyncResult(success=False, errorMessage=f"HTTP {resp.status}: {body[:200]}") except Exception as e: logger.debug("RMA getBookingByExternalId failed: %s", e) return SyncResult(success=False, errorMessage=str(e)) async def isBookingSynced(self, config: Dict[str, Any], booking: AccountingBooking) -> SyncResult: """Check if booking exists in RMA by searching transactions for reference match. Uses GET charts/{accno}/transactions with date filter. success=True = booking exists.""" if not booking.lines: return SyncResult(success=True) ref = (booking.reference or "").strip()[:32] if not ref: return SyncResult(success=True) fromDate = (booking.bookingDate or "").split("T")[0].strip()[:10] if not fromDate or len(fromDate) < 10: return SyncResult(success=True) accountNumbers = list({ln.accountNumber for ln in booking.lines if ln.accountNumber}) if not accountNumbers: return SyncResult(success=True) try: async with aiohttp.ClientSession() as session: for accno in accountNumbers: url = self._buildUrl(config, f"charts/{accno}/transactions") params = {"from_date": fromDate, "to_date": fromDate} async with session.get( url, headers=self._buildHeaders(config), params=params, timeout=aiohttp.ClientTimeout(total=15) ) as resp: if resp.status != 200: continue data = await resp.json() transactions = data.get("transaction") if isinstance(data, dict) else [] if isinstance(data, list): transactions = data if not isinstance(transactions, list): transactions = [transactions] if isinstance(transactions, dict) else [] for t in transactions: if isinstance(t, dict) and (t.get("reference") or "").strip() == ref: return SyncResult(success=True) return SyncResult(success=False, errorMessage="Reference not found in RMA transactions") except asyncio.TimeoutError: logger.debug("RMA isBookingSynced timeout – trust local") return SyncResult(success=True) except Exception as e: logger.debug("RMA isBookingSynced error: %s – trust local", e) return SyncResult(success=True) 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 GL entries from RMA. Strategy: first try GET /gl (bulk), then fall back to iterating account transactions. Uses pre-fetched accountNumbers if provided. """ try: params: Dict[str, str] = {} if dateFrom: params["from_date"] = dateFrom if dateTo: params["to_date"] = dateTo # Try bulk GL endpoint first bulkEntries = await self._fetchGlBulk(config, params) if bulkEntries: return bulkEntries # Fallback: iterate accounts and fetch transactions if accountNumbers: accNums = accountNumbers else: chart = await self.getChartOfAccounts(config) accNums = [acc.accountNumber for acc in chart if acc.accountNumber] if not accNums: return [] entriesByRef: Dict[str, Dict[str, Any]] = {} fetchedCount = 0 emptyCount = 0 errorCount = 0 async with aiohttp.ClientSession() as session: for accNo in accNums: url = self._buildUrl(config, f"charts/{accNo}/transactions") try: async with session.get(url, headers=self._buildHeaders(config), params=params, timeout=aiohttp.ClientTimeout(total=10)) as resp: if resp.status != 200: emptyCount += 1 continue body = await resp.text() if not body.strip(): emptyCount += 1 continue try: data = json.loads(body) except Exception: errorCount += 1 continue except (asyncio.TimeoutError, Exception): errorCount += 1 continue fetchedCount += 1 if isinstance(data, dict): transactions = data.get("transaction") or data.get("@transaction") else: transactions = data if isinstance(transactions, dict): transactions = [transactions] if not isinstance(transactions, list): continue for t in transactions: if not isinstance(t, dict): continue ref = t.get("reference") or t.get("@reference") or t.get("batch_number") or str(t.get("id") or "") transDate = str(t.get("transdate") or t.get("@transdate") or "").split("T")[0] desc = t.get("description") or t.get("memo") or t.get("@description") or "" rawAmount = float(t.get("amount") or t.get("@amount") or 0) debit = rawAmount if rawAmount > 0 else 0.0 credit = abs(rawAmount) if rawAmount < 0 else 0.0 if ref not in entriesByRef: entriesByRef[ref] = { "externalId": str(t.get("id") or t.get("@id") or ref), "bookingDate": transDate, "reference": ref, "description": desc, "currency": "CHF", "totalAmount": 0.0, "lines": [], } entry = entriesByRef[ref] entry["lines"].append({ "accountNumber": accNo, "debitAmount": debit, "creditAmount": credit, "description": desc, }) entry["totalAmount"] += max(debit, credit) return list(entriesByRef.values()) except Exception as e: logger.error(f"RMA getJournalEntries error: {e}", exc_info=True) return [] async def _fetchGlBulk(self, config: Dict[str, Any], params: Dict[str, str]) -> List[Dict[str, Any]]: """Try GET /gl to fetch journal entries in bulk (not all RMA versions support this).""" try: async with aiohttp.ClientSession() as session: url = self._buildUrl(config, "gl") async with session.get(url, headers=self._buildHeaders(config), params=params, timeout=aiohttp.ClientTimeout(total=60)) as resp: if resp.status != 200: return [] body = await resp.text() if not body.strip(): return [] try: data = json.loads(body) except Exception: return [] items = data if isinstance(data, list) else (data.get("gl_batch") or data.get("gl") or data.get("items") or []) if isinstance(items, dict): items = [items] if not isinstance(items, list): return [] entries = [] for batch in items: if not isinstance(batch, dict): continue transDate = str(batch.get("transdate") or batch.get("date") or "").split("T")[0] ref = batch.get("batch_number") or batch.get("reference") or str(batch.get("id", "")) desc = batch.get("description") or batch.get("notes") or "" rawTxns = batch.get("gl_transactions", {}) txnList = rawTxns.get("gl_transaction") if isinstance(rawTxns, dict) else rawTxns if isinstance(txnList, dict): txnList = [txnList] if not isinstance(txnList, list): txnList = [] lines = [] totalAmt = 0.0 for t in txnList: if not isinstance(t, dict): continue debit = float(t.get("debit_amount") or 0) credit = float(t.get("credit_amount") or 0) lines.append({ "accountNumber": str(t.get("accno", "")), "debitAmount": debit, "creditAmount": credit, "description": t.get("memo", ""), }) totalAmt += max(debit, credit) entries.append({ "externalId": str(batch.get("id", ref)), "bookingDate": transDate, "reference": ref, "description": desc, "currency": batch.get("currency", "CHF"), "totalAmount": totalAmt, "lines": lines, }) return entries except Exception as e: logger.debug(f"RMA _fetchGlBulk not available: {e}") return [] 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 self._parseJsonOrXmlList(resp, "customer") return data 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 self._parseJsonOrXmlList(resp, "vendor") return data except Exception as e: logger.error(f"RMA getVendors error: {e}") return [] async def _parseJsonOrXmlList(self, resp: aiohttp.ClientResponse, itemKey: str) -> List[Dict[str, Any]]: """Parse RMA response that may be JSON or XML. Returns list of dicts.""" body = await resp.text() if not body or not body.strip(): return [] try: data = json.loads(body) if isinstance(data, list): return data if isinstance(data, dict): items = data.get(itemKey) or data.get("items") or data.get("row") or [] if isinstance(items, dict): return [items] return items if isinstance(items, list) else [] return [] except (json.JSONDecodeError, ValueError): pass result: List[Dict[str, Any]] = [] ids = re.findall(r"([^<]+)", body) names = re.findall(r"([^<]+)", body) for i, rid in enumerate(ids): entry: Dict[str, Any] = {"id": rid.strip()} if i < len(names): entry["name"] = names[i].strip() result.append(entry) return result async def _findBelegByFilename(self, config: Dict[str, Any], session: aiohttp.ClientSession, fileName: str) -> Optional[str]: """Try GET /belege (undocumented) to find an existing beleg by filename.""" try: url = self._buildUrl(config, "belege") async with session.get(url, headers=self._buildHeaders(config), timeout=aiohttp.ClientTimeout(total=15)) as resp: body = await resp.text() logger.info("RMA GET /belege: status=%s, body=%s", resp.status, body[:1000]) if resp.status != 200: return None try: data = json.loads(body) except Exception: data = None if isinstance(data, list): items = data elif isinstance(data, dict): items = data.get("beleg_upload") or data.get("belege") or data.get("row") or [] if isinstance(items, dict): items = [items] else: # Try XML: extract all + pairs ids = re.findall(r"([^<]+)", body) names = re.findall(r"([^<]+)", body) for bid, fname in zip(ids, names): if fileName.lower() in fname.lower(): logger.info("RMA GET /belege: matched filename=%s → belegId=%s", fname, bid) return bid.strip() return None if not isinstance(items, list): return None for item in items: if not isinstance(item, dict): continue fname = item.get("file_name") or item.get("fileName") or "" if fileName.lower() in fname.lower(): bid = item.get("id") if bid: logger.info("RMA GET /belege: matched filename=%s → belegId=%s", fname, bid) return str(bid).strip() except Exception as e: logger.debug("RMA _findBelegByFilename failed: %s", e) return None def _parseExistingBelegId(self, body: str) -> Optional[str]: """Try to extract the existing Beleg-ID from a 409 duplicate response. Tries multiple patterns.""" if not (body or "").strip(): return None # Pattern: "id":12345 or "id":"12345" in JSON match = re.search(r'"id"\s*:\s*"?(\d+)"?', body) if match: return match.group(1).strip() # Pattern: 12345 in XML match = re.search(r"([^<]+)", body) if match: return match.group(1).strip() # Pattern: numeric id in URL like /file/12345 match = re.search(r"/file/(\d+)", body) if match: return match.group(1).strip() return None def _parseBelegIdFromResponse(self, body: str) -> Optional[str]: """Extract Beleg-ID from RMA POST /belege response (XML or JSON).""" if not (body or "").strip(): return None try: data = json.loads(body) if isinstance(data, dict): bid = data.get("id") or (data.get("beleg_upload") or {}).get("id") if bid is not None: return str(bid).strip() except Exception: pass match = re.search(r"([^<]+)", body) if match: return match.group(1).strip() return None async def uploadDocument( self, config: Dict[str, Any], fileName: str, fileContent: bytes, mimeType: str = "application/pdf", comment: Optional[str] = None, ) -> SyncResult: """Upload a receipt via POST /belege (multipart/form-data). Returns SyncResult with externalId = Beleg-ID when present.""" try: formData = aiohttp.FormData() formData.add_field("Filedata", fileContent, filename=fileName, content_type=mimeType) if comment: formData.add_field("comment", comment[:500]) headers = self._buildHeaders(config) headers.pop("Content-Type", None) # let aiohttp set multipart boundary headers["Accept"] = "*/*" # RMA may return XML on error; avoid 406 Not Acceptable 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() belegId = self._parseBelegIdFromResponse(body) if resp.status in (200, 201): return SyncResult( success=True, externalId=belegId, rawResponse={"body": body[:500]}, ) if resp.status == 409: logger.info("RMA uploadDocument 409 (duplicate), body=%s", body[:500]) if not belegId: belegId = self._parseExistingBelegId(body) if not belegId: belegId = await self._findBelegByFilename(config, session, fileName) return SyncResult( success=True, externalId=belegId, rawResponse={"body": body[:500]}, ) errMsg = f"HTTP {resp.status}: {body[:300]}" logger.error("RMA uploadDocument failed: status=%s, body=%s", resp.status, body[:500]) return SyncResult(success=False, errorMessage=errMsg) except Exception as e: logger.exception("RMA uploadDocument error") return SyncResult(success=False, errorMessage=str(e))