960 lines
44 KiB
Python
960 lines
44 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 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))
|