# 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 calendar
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,
AccountingPeriodBalance,
ConnectorConfigField,
SyncResult,
)
from modules.shared.i18nRegistry import t
logger = logging.getLogger(__name__)
_DEFAULT_API_BASE_URL = "https://service.runmyaccounts.com/api/latest/clients/"
def _formatLastDayOfMonth(year: int, month: int) -> str:
"""Return ``YYYY-MM-DD`` of the last day of a calendar month."""
lastDay = calendar.monthrange(year, month)[1]
return f"{year:04d}-{month:02d}-{lastDay:02d}"
def _isIncomeStatementAccount(accountNumber: str) -> bool:
"""Decide whether an account is part of the income statement (Erfolgsrechnung).
Swiss KMU-Kontenrahmen: 1xxx Aktiven, 2xxx Passiven (incl. 28xx
Eigenkapital) -> balance sheet; 3xxx..9xxx -> income statement.
Used by the RMA connector to choose between the two `/gl/saldo` query
variants (with vs. without ``from`` parameter).
"""
a = (accountNumber or "").strip()
if not a or not a[0].isdigit():
return False
return a[0] not in ("1", "2")
def _parseSaldoBody(body: str) -> List[tuple]:
"""Parse the response body of ``GET /gl/saldo`` (JSON or XML).
Returns a list of ``(accountNumber, saldo)`` tuples. The endpoint
delivers ``{"row": [{"column": [accno, label, saldo]}, ...]}`` (JSON) or
``
accnolabelsaldo
...``
(XML). Rows that cannot be parsed are silently skipped to keep one bad row
from poisoning the whole sync.
"""
if not body or not body.strip():
return []
rows: List[tuple] = []
try:
data = json.loads(body)
items = data.get("row") if isinstance(data, dict) else data
if isinstance(items, dict):
items = [items]
if isinstance(items, list):
for item in items:
if not isinstance(item, dict):
continue
cols = item.get("column") or []
if isinstance(cols, list) and len(cols) >= 3:
accno = str(cols[0]).strip()
try:
saldo = float(cols[2])
except (TypeError, ValueError):
continue
if accno:
rows.append((accno, saldo))
return rows
except (json.JSONDecodeError, ValueError):
pass
rowMatches = re.findall(r"(.*?)
", body, re.DOTALL)
for raw in rowMatches:
cols = re.findall(r"([^<]*)", raw)
if len(cols) >= 3:
accno = cols[0].strip()
try:
saldo = float(cols[2])
except (TypeError, ValueError):
continue
if accno:
rows.append((accno, saldo))
return rows
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/",
suggestions=[
"https://service.runmyaccounts.com/api/latest/clients/",
"https://service.int.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,
})
# Booking total = sum of debits (== sum of credits for a balanced
# booking). Summing max(debit, credit) per line would double-count
# a balanced 2-line booking (200 instead of 100).
entry["totalAmount"] += debit
return list(entriesByRef.values())
except Exception as e:
logger.error(f"RMA getJournalEntries error: {e}", exc_info=True)
return []
async def getAccountBalances(
self,
config: Dict[str, Any],
years: List[int],
accountNumbers: Optional[List[str]] = None,
) -> List[AccountingPeriodBalance]:
"""Fetch authoritative closing balances per account and period via RMA's
``GET /gl/saldo`` endpoint.
For each requested year we issue 13 API calls (one per month-end + one
for the prior fiscal year-end as opening reference). The endpoint
returns the cumulative balance per account at the requested ``to`` date,
already including prior-year carry-over and yearend bookings -- which
is exactly the value the local journal-line aggregation cannot
reconstruct when the import window covers only part of the history.
``accno`` is mandatory; we use a digit-length-grouped wildcard
(``xxxx`` matches all 4-digit accounts, ``xxxxx`` all 5-digit, etc.)
derived from the chart of accounts, so 1-2 calls cover every account
per period.
"""
if not years:
return []
accountNumbersSet: Optional[set] = set(accountNumbers) if accountNumbers else None
wildcardPatterns = await self._resolveWildcardPatterns(config)
if not wildcardPatterns:
logger.warning("RMA getAccountBalances: chart of accounts is empty, no wildcards derivable")
return []
results: List[AccountingPeriodBalance] = []
sortedYears = sorted({int(y) for y in years if y})
for year in sortedYears:
priorYearEnd = f"{year - 1}-12-31"
priorSaldosRaw = await self._fetchSaldoMapForDate(config, wildcardPatterns, priorYearEnd)
# ER (income statement) accounts reset to 0 at the start of each
# fiscal year -- prior-year YTD must NOT carry forward as opening.
priorSaldos = {a: (0.0 if _isIncomeStatementAccount(a) else v) for a, v in priorSaldosRaw.items()}
runningOpening: Dict[str, float] = dict(priorSaldos)
decSaldos: Dict[str, float] = {}
for month in range(1, 13):
lastDay = _formatLastDayOfMonth(year, month)
saldos = await self._fetchSaldoMapForDate(config, wildcardPatterns, lastDay)
accountKeys = set(saldos.keys()) | set(runningOpening.keys())
for accno in accountKeys:
if accountNumbersSet is not None and accno not in accountNumbersSet:
continue
closing = saldos.get(accno, runningOpening.get(accno, 0.0))
opening = runningOpening.get(accno, 0.0)
results.append(AccountingPeriodBalance(
accountNumber=accno,
periodYear=year,
periodMonth=month,
openingBalance=round(opening, 2),
closingBalance=round(closing, 2),
currency="CHF",
asOfDate=lastDay,
))
runningOpening = {**runningOpening, **saldos}
if month == 12:
decSaldos = dict(saldos)
annualKeys = set(decSaldos.keys()) | set(priorSaldos.keys())
for accno in annualKeys:
if accountNumbersSet is not None and accno not in accountNumbersSet:
continue
closing = decSaldos.get(accno, priorSaldos.get(accno, 0.0))
opening = priorSaldos.get(accno, 0.0)
results.append(AccountingPeriodBalance(
accountNumber=accno,
periodYear=year,
periodMonth=0,
openingBalance=round(opening, 2),
closingBalance=round(closing, 2),
currency="CHF",
asOfDate=f"{year}-12-31",
))
logger.info(
"RMA getAccountBalances: %s rows for years=%s, wildcards=%s",
len(results), sortedYears, wildcardPatterns,
)
return results
async def _resolveWildcardPatterns(self, config: Dict[str, Any]) -> List[str]:
"""Derive `accno` wildcard patterns from the chart of accounts.
RMA's `/gl/saldo` requires `accno`; using digit-length-grouped
wildcards (`xxxx`, `xxxxx`, ...) lets us cover every account in 1-2
calls per period instead of one call per account number.
"""
try:
charts = await self.getChartOfAccounts(config)
except Exception as ex:
logger.warning("RMA _resolveWildcardPatterns: getChartOfAccounts failed: %s", ex)
return []
lengths = set()
for c in charts:
accno = (c.accountNumber or "").strip()
if accno.isdigit():
lengths.add(len(accno))
return [("x" * n) for n in sorted(lengths)]
async def _fetchSaldoMapForDate(
self,
config: Dict[str, Any],
wildcardPatterns: List[str],
toDate: str,
) -> Dict[str, float]:
"""Call `/gl/saldo` and return ``{accountNumber: cumulativeSaldo}``.
Per RMA docs ("Warning: Chart of the balance sheet do not need a from
date. Charts of the income statement need from and to parameter."),
we issue **two** calls per pattern:
* No ``from`` -> correct cumulative saldo for balance-sheet accounts
(1xxx, 2xxx in Swiss KMU-Kontenrahmen).
* ``from=YYYY-01-01`` (year of ``toDate``) -> correct YTD result for
income-statement accounts (3xxx..9xxx, which reset annually).
Per account number we keep the value from the appropriate call.
Empty / failed responses are logged at DEBUG and skipped to avoid
aborting the whole sync.
"""
yearStart = f"{toDate[:4]}-01-01"
bsRows: Dict[str, float] = {}
erRows: Dict[str, float] = {}
for pattern in wildcardPatterns:
try:
bs = await self._fetchSaldoRows(config, accno=pattern, fromDate=None, toDate=toDate)
except Exception as ex:
logger.debug("RMA _fetchSaldoMapForDate(BS, pattern=%s, to=%s) failed: %s", pattern, toDate, ex)
bs = []
try:
er = await self._fetchSaldoRows(config, accno=pattern, fromDate=yearStart, toDate=toDate)
except Exception as ex:
logger.debug("RMA _fetchSaldoMapForDate(ER, pattern=%s, %s..%s) failed: %s", pattern, yearStart, toDate, ex)
er = []
for accno, saldo in bs:
bsRows[accno] = saldo
for accno, saldo in er:
erRows[accno] = saldo
merged: Dict[str, float] = {}
for accno in set(bsRows) | set(erRows):
if _isIncomeStatementAccount(accno):
merged[accno] = erRows.get(accno, bsRows.get(accno, 0.0))
else:
merged[accno] = bsRows.get(accno, erRows.get(accno, 0.0))
return merged
async def _fetchSaldoRows(
self,
config: Dict[str, Any],
accno: str,
fromDate: Optional[str],
toDate: str,
) -> List[tuple]:
"""Single `/gl/saldo` call. Returns list of ``(accountNumber, saldo)`` tuples."""
url = self._buildUrl(config, "gl/saldo")
params: Dict[str, str] = {
"accno": accno,
"to": toDate,
"bookkeeping_main_curr": "true",
}
if fromDate:
params["from"] = fromDate
async with aiohttp.ClientSession() as session:
async with session.get(
url,
headers=self._buildHeaders(config),
params=params,
timeout=aiohttp.ClientTimeout(total=20),
) as resp:
if resp.status != 200:
body = await resp.text()
logger.debug("RMA /gl/saldo accno=%s from=%s to=%s -> HTTP %s: %s", accno, fromDate, toDate, resp.status, body[:200])
return []
body = await resp.text()
return _parseSaldoBody(body)
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", ""),
})
# Sum debits only -- equals sum of credits for a balanced
# booking. max(debit, credit) per line would double-count.
totalAmt += debit
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))