qonto-assistant/src/qonto_assistant/qonto_client.py
tegwick ca12843013 Complete Phase 1: policy kernel, REST service, and local smoke tooling
Implements QONTO-WP-0002 (policy-gated Qonto REST service with audit
logging, rate limiting, and credential handling) and the ADHOC-2026-07-21
follow-up (fixture-backed local smoke mode, repo classification metadata).
Marks QONTO-WP-0001/0002 and the ad-hoc workplan finished, and regenerates
WORK-RECORDS.md and the ADHOC workplan's state_hub_workstream_id via
fix-consistency.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 21:21:05 +02:00

246 lines
8.3 KiB
Python

from __future__ import annotations
import json
from collections.abc import Mapping
from datetime import datetime
from pathlib import Path
from typing import Any
from typing import Protocol
import httpx
from qonto_assistant.contracts import QontoCredentials
from qonto_assistant.credentials import EnvironmentCredentialProvider, OpenBaoCliCredentialProvider
from qonto_assistant.errors import UpstreamError
class QontoClientProtocol(Protocol):
def get_organization(self) -> Mapping[str, Any]: ...
def list_transactions(
self,
*,
iban: str,
page: int,
page_size: int,
window_days: int,
status: str | None,
side: str | None,
) -> Mapping[str, Any]: ...
def close(self) -> None: ...
class QontoClient:
def __init__(
self,
*,
base_url: str,
organization_path: str,
transactions_path: str,
auth_mode: str,
timeout_seconds: float,
max_retries: int,
credential_provider: EnvironmentCredentialProvider | OpenBaoCliCredentialProvider,
transport: httpx.BaseTransport | None = None,
) -> None:
self.base_url = base_url.rstrip("/")
self.organization_path = organization_path
self.transactions_path = transactions_path
self.auth_mode = auth_mode
self.max_retries = max_retries
self.credential_provider = credential_provider
self.client = httpx.Client(
base_url=self.base_url,
timeout=httpx.Timeout(timeout_seconds),
transport=transport,
)
def get_organization(self) -> Mapping[str, Any]:
return self._request("GET", self.organization_path)
def list_transactions(
self,
*,
iban: str,
page: int,
page_size: int,
window_days: int,
status: str | None,
side: str | None,
) -> Mapping[str, Any]:
params: dict[str, Any] = {
"iban": iban,
"current_page": page,
"per_page": page_size,
"window_days": window_days,
}
if status:
params["status"] = status
if side:
params["side"] = side
return self._request("GET", self.transactions_path, params=params)
def close(self) -> None:
self.client.close()
def _request(
self,
method: str,
path: str,
*,
params: Mapping[str, Any] | None = None,
) -> Mapping[str, Any]:
credentials = self.credential_provider.get_credentials()
headers = self._headers(credentials)
attempt = 0
while True:
try:
response = self.client.request(method, path, headers=headers, params=params)
except httpx.TimeoutException as exc:
if attempt < self.max_retries:
attempt += 1
continue
raise UpstreamError("Qonto request timed out", error_code="qonto_timeout", status_code=504) from exc
except httpx.TransportError as exc:
if attempt < self.max_retries:
attempt += 1
continue
raise UpstreamError(
"Qonto transport failure",
error_code="qonto_transport_error",
status_code=502,
) from exc
if response.status_code in {401, 403}:
self.credential_provider.invalidate()
raise UpstreamError(
"Qonto authentication failed",
error_code="qonto_auth_failed",
status_code=502,
upstream_status=response.status_code,
)
if response.status_code >= 500 and attempt < self.max_retries:
attempt += 1
continue
if response.is_error:
raise UpstreamError(
f"Qonto responded with HTTP {response.status_code}",
error_code="qonto_upstream_error",
status_code=502,
upstream_status=response.status_code,
)
payload = response.json()
if not isinstance(payload, Mapping):
raise UpstreamError(
"Qonto returned a non-object payload",
error_code="qonto_invalid_payload",
status_code=502,
upstream_status=response.status_code,
)
return payload
def _headers(self, credentials: QontoCredentials) -> dict[str, str]:
if self.auth_mode == "bearer":
return {"Authorization": f"Bearer {credentials.api_key}"}
if self.auth_mode != "legacy_api_key":
raise UpstreamError(
f"Unsupported auth mode: {self.auth_mode}",
error_code="qonto_auth_mode",
status_code=500,
)
return {"Authorization": f"{credentials.api_user}:{credentials.api_key}"}
class FixtureQontoClient:
def __init__(self, *, fixture_dir: Path) -> None:
self.fixture_dir = fixture_dir
self._organization_payload = self._load("organization.json")
self._transactions_payload = self._load("transactions.json")
def get_organization(self) -> Mapping[str, Any]:
return self._organization_payload
def list_transactions(
self,
*,
iban: str,
page: int,
page_size: int,
window_days: int,
status: str | None,
side: str | None,
) -> Mapping[str, Any]:
transactions = self._transactions_payload.get("transactions", [])
if not isinstance(transactions, list):
raise UpstreamError(
"Fixture transactions payload is invalid",
error_code="qonto_fixture_invalid",
status_code=500,
)
filtered: list[Mapping[str, Any]] = [item for item in transactions if isinstance(item, Mapping)]
if iban:
filtered = [item for item in filtered if item.get("iban") in {None, "", iban}]
if status:
filtered = [item for item in filtered if item.get("status") == status]
if side:
filtered = [item for item in filtered if item.get("side") == side]
filtered = self._filter_window(filtered, window_days)
start = max((page - 1) * page_size, 0)
end = start + page_size
return {
"transactions": filtered[start:end],
"meta": {
"total": len(filtered),
"page": page,
"page_size": page_size,
"source": "fixture",
},
}
def close(self) -> None:
return None
def _load(self, name: str) -> Mapping[str, Any]:
path = self.fixture_dir / name
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError as exc:
raise UpstreamError(
f"Fixture file missing: {path}",
error_code="qonto_fixture_missing",
status_code=500,
) from exc
if not isinstance(payload, Mapping):
raise UpstreamError(
f"Fixture payload must be an object: {path}",
error_code="qonto_fixture_invalid",
status_code=500,
)
return payload
def _filter_window(self, transactions: list[Mapping[str, Any]], window_days: int) -> list[Mapping[str, Any]]:
dated = []
for transaction in transactions:
settled_at = transaction.get("settled_at") or transaction.get("settledAt")
if not settled_at:
dated.append((None, transaction))
continue
dated.append((datetime.fromisoformat(str(settled_at).replace("Z", "+00:00")), transaction))
dates = [item[0] for item in dated if item[0] is not None]
if not dates:
return transactions
reference = max(dates)
filtered: list[Mapping[str, Any]] = []
for parsed, transaction in dated:
if parsed is None:
filtered.append(transaction)
continue
age_days = (reference - parsed).days
if age_days < window_days:
filtered.append(transaction)
return filtered