481 lines
24 KiB
Python
481 lines
24 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: 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,
|
||
)
|
||
|
||
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", "")
|
||
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", "")
|
||
|
||
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)
|
||
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 = []
|
||
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'<a href="{docUrl}">{label}</a>')
|
||
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 <id> + <file_name> pairs
|
||
ids = re.findall(r"<id>([^<]+)</id>", body)
|
||
names = re.findall(r"<file_name>([^<]+)</file_name>", 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: <id>12345</id> in XML
|
||
match = re.search(r"<id>([^<]+)</id>", 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"<id>([^<]+)</id>", 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))
|