diff --git a/broker_sync/sinks/wealthfolio.py b/broker_sync/sinks/wealthfolio.py index a7c2654..e6dce17 100644 --- a/broker_sync/sinks/wealthfolio.py +++ b/broker_sync/sinks/wealthfolio.py @@ -1,7 +1,5 @@ from __future__ import annotations -import csv -import io import json from collections.abc import Iterable from pathlib import Path @@ -149,22 +147,34 @@ class WealthfolioSink: # -- activity import -- @staticmethod - def _activities_csv(activities: Iterable[Activity]) -> str: - activities = list(activities) - if not activities: - return "" - rows = [a.to_wealthfolio_csv_row() for a in activities] - buf = io.StringIO() - w = csv.DictWriter(buf, fieldnames=list(rows[0].keys())) - w.writeheader() - w.writerows(rows) - return buf.getvalue() + def _activity_to_import_row(a: Activity) -> dict[str, Any]: + """Match Wealthfolio's ActivityImport struct (camelCase JSON).""" + row: dict[str, Any] = { + "date": a.date.isoformat(), + "symbol": a.symbol or "$CASH", + "activityType": str(a.activity_type), + "currency": a.currency, + "accountId": a.account_id, + } + if a.quantity is not None: + row["quantity"] = format(a.quantity, "f") + if a.unit_price is not None: + row["unitPrice"] = format(a.unit_price, "f") + if a.amount is not None: + row["amount"] = format(a.amount, "f") + if a.fee: + row["fee"] = format(a.fee, "f") + if a.notes: + row["comment"] = a.notes + return row async def import_activities(self, activities: Iterable[Activity]) -> list[dict[str, Any]]: - csv_text = self._activities_csv(activities) - files = {"file": ("activities.csv", csv_text, "text/csv")} + rows = [self._activity_to_import_row(a) for a in activities] + if not rows: + return [] + body = {"activities": rows} - check = await self._request("POST", _IMPORT_CHECK, files=files) + check = await self._request("POST", _IMPORT_CHECK, json=body) if check.status_code >= 400: try: payload = check.json() @@ -172,9 +182,26 @@ class WealthfolioSink: payload = {"raw": check.text} raise ImportValidationError(f"Wealthfolio /import/check rejected: {payload}") - # Re-send the same CSV to the real endpoint. - real = await self._request("POST", _IMPORT_REAL, files=files) + # Dry-run may flag per-row errors inside a 200 response. + checked = check.json() + if isinstance(checked, list): + bad = [r for r in checked if isinstance(r, dict) and r.get("errors")] + if bad: + # Show the first few to aid debugging without leaking everything. + raise ImportValidationError( + f"Wealthfolio /import/check flagged {len(bad)} rows; " + f"first: {bad[0]}" + ) + + real = await self._request("POST", _IMPORT_REAL, json=body) real.raise_for_status() raw = real.json() - assert isinstance(raw, list) - return raw + # import_activities returns ImportActivitiesResult which is an object, + # but we also accept a list (older versions). Normalise. + if isinstance(raw, dict) and "activities" in raw: + got = raw["activities"] + assert isinstance(got, list) + return got + if isinstance(raw, list): + return raw + return []