# Copyright (c) 2025 Patrick Motsch # All rights reserved. """Run My Accounts (Infoniqa) accounting connector. API docs: https://runmyaccountsag.github.io/runmyaccounts-rest-api/ Auth: API key (incl. ``pat_`` tokens since Sep 2025) via ``X-RMA-KEY`` request header. 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, ) 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]: apiKey = config.get("apiKey", "") return { "X-RMA-KEY": apiKey, "Accept": "application/json, application/xml, */*", "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) logger.info("RMA testConnection: url=%s, clientName=%s, apiKey=%s...", url, clientName, apiKey[:6] if len(apiKey) > 6 else "***") 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() 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 = [] for item in items: if isinstance(item, dict): accNo = str(item.get("accno", item.get("account_number", ""))) label = str(item.get("description", item.get("label", ""))) 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] 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 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 _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))