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