# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""Run My Accounts (Infoniqa) accounting connector.
API docs: https://runmyaccountsag.github.io/runmyaccounts-rest-api/
Auth: PAT tokens (``pat_...``) via ``Authorization: Bearer``.
Fallback for legacy API keys via ``X-RMA-KEY``.
Base URL: https://service.runmyaccounts.com/api/latest/clients/{clientName}/
"""
import asyncio
import json
import logging
import re
from datetime import datetime
from typing import List, Dict, Any, Optional
import aiohttp
from ..accountingConnectorBase import (
BaseAccountingConnector,
AccountingBooking,
AccountingChart,
ConnectorConfigField,
SyncResult,
)
from modules.shared.i18nRegistry import t
logger = logging.getLogger(__name__)
_DEFAULT_API_BASE_URL = "https://service.runmyaccounts.com/api/latest/clients/"
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/",
),
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,
})
entry["totalAmount"] += max(debit, credit)
return list(entriesByRef.values())
except Exception as e:
logger.error(f"RMA getJournalEntries error: {e}", exc_info=True)
return []
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", ""),
})
totalAmt += max(debit, credit)
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))