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>
This commit is contained in:
parent
eef408bb19
commit
ca12843013
33 changed files with 2533 additions and 30 deletions
246
src/qonto_assistant/qonto_client.py
Normal file
246
src/qonto_assistant/qonto_client.py
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue