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