gateway/modules/features/trustee/accounting/connectors/accountingConnectorRma.py
2026-04-26 08:31:35 +02:00

960 lines
44 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
``<table><row><column>accno</column><column>label</column><column>saldo</column></row>...``
(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"<row>(.*?)</row>", body, re.DOTALL)
for raw in rowMatches:
cols = re.findall(r"<column>([^<]*)</column>", 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'<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]
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"<id>([^<]+)</id>", body)
names = re.findall(r"<name>([^<]+)</name>", 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 <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))