sinks: switch Wealthfolio import to JSON body (not multipart CSV)

/api/v1/activities/import expects Content-Type: application/json with body
{"activities": [ActivityImport, ...]} where ActivityImport is camelCase:
{date, symbol, activityType, quantity?, unitPrice?, currency, fee?, amount?,
comment?, accountId?, ...}. Source: crates/core/src/activities/activities_model.rs

Live run failure was HTTP 415 Unsupported Media Type because we were uploading
a CSV in multipart/form-data; that endpoint is JSON-only.

Also handle two response shapes on import — older builds return a list, current
build returns {activities: [...]}.

Test plan:
  poetry run pytest -q   →  70 passed
  poetry run mypy        →  clean
  poetry run ruff check  →  clean
This commit is contained in:
Viktor Barzin 2026-04-17 20:34:12 +00:00
parent ea881e272b
commit 1d23bf6ed7

View file

@ -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 []