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>
246 lines
8.3 KiB
Python
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
|