qonto-assistant/src/qonto_assistant/service.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

403 lines
14 KiB
Python

from __future__ import annotations
import time
from collections import defaultdict
from collections.abc import Mapping
from datetime import UTC, datetime
from typing import Any
from qonto_assistant.audit import AuditLogger, utc_now_iso
from qonto_assistant.contracts import ActorClaims, AuditEvent, CapabilityRequest
from qonto_assistant.errors import InvalidRequestError, PolicyDeniedError, UpstreamError
from qonto_assistant.policy import PolicyEngine
from qonto_assistant.qonto_client import QontoClientProtocol
from qonto_assistant.rate_limits import ConcurrencyLimiter, RateLimiter
class CapabilityService:
def __init__(
self,
*,
client: QontoClientProtocol,
policy: PolicyEngine,
audit_logger: AuditLogger,
rate_limiter: RateLimiter,
concurrency_limiter: ConcurrencyLimiter,
) -> None:
self.client = client
self.policy = policy
self.audit_logger = audit_logger
self.rate_limiter = rate_limiter
self.concurrency_limiter = concurrency_limiter
def get_accounts(self, *, claims: ActorClaims, request_id: str) -> dict[str, Any]:
return self._execute(
capability_id="org_summary",
claims=claims,
request_args={},
resource_scope="accounts",
request_id=request_id,
operation=self._build_accounts_payload,
)
def list_transactions(
self,
*,
claims: ActorClaims,
request_id: str,
account_slug: str | None,
page: int,
page_size: int,
window_days: int,
status: str | None,
side: str | None,
) -> dict[str, Any]:
return self._execute(
capability_id="list_transactions",
claims=claims,
request_args={
"account_slug": account_slug,
"page": page,
"page_size": page_size,
"window_days": window_days,
"status": status,
"side": side,
},
resource_scope="transactions",
request_id=request_id,
operation=lambda _: self._build_transactions_payload(
account_slug=account_slug,
page=page,
page_size=page_size,
window_days=window_days,
status=status,
side=side,
),
)
def get_snapshot(
self,
*,
claims: ActorClaims,
request_id: str,
window_days: int,
page_size: int,
) -> dict[str, Any]:
return self._execute(
capability_id="snapshot_bundle",
claims=claims,
request_args={"window_days": window_days, "page_size": page_size},
resource_scope="snapshot",
request_id=request_id,
operation=lambda _: self._build_snapshot_payload(window_days=window_days, page_size=page_size),
)
def _execute(
self,
*,
capability_id: str,
claims: ActorClaims,
request_args: dict[str, Any],
resource_scope: str,
request_id: str,
operation,
) -> dict[str, Any]:
request = CapabilityRequest(
capability_id=capability_id,
tenant_id=claims.tenant_id,
actor_claims=claims,
resource_scope=resource_scope,
request_args=request_args,
protocol="rest",
)
started = time.perf_counter()
decision = self.policy.decide(request)
if not decision.allowed:
self._emit_audit(
request_id=request_id,
claims=claims,
capability_id=capability_id,
decision="deny",
deny_reason=decision.reason,
latency_ms=_latency_ms(started),
result_count=None,
qonto_http_status=None,
)
raise PolicyDeniedError(decision)
actor_key = f"{claims.tenant_id}:{claims.actor_id}"
self.rate_limiter.check(actor_key)
with self.concurrency_limiter.slot(actor_key):
payload = operation(decision.request_args)
self._emit_audit(
request_id=request_id,
claims=claims,
capability_id=capability_id,
decision="allow",
deny_reason=None,
latency_ms=_latency_ms(started),
result_count=_result_count(payload),
qonto_http_status=200,
)
return payload
def _build_accounts_payload(self, _: Mapping[str, Any]) -> dict[str, Any]:
organization_payload = self.client.get_organization()
organization = _extract_organization(organization_payload)
accounts = [_normalize_account(account) for account in _extract_accounts(organization_payload)]
return {
"organization": _normalize_organization(organization),
"accounts": accounts,
"totals": {
"balance": round(sum(account["balance"] for account in accounts), 2),
"authorized_balance": round(sum(account["authorized_balance"] for account in accounts), 2),
},
}
def _build_transactions_payload(
self,
*,
account_slug: str | None,
page: int,
page_size: int,
window_days: int,
status: str | None,
side: str | None,
) -> dict[str, Any]:
organization_payload = self.client.get_organization()
organization = _extract_organization(organization_payload)
accounts = _extract_accounts(organization_payload)
selected_account = _select_account(accounts, account_slug)
raw_transactions = self.client.list_transactions(
iban=selected_account["iban"],
page=page,
page_size=page_size,
window_days=window_days,
status=status,
side=side,
)
transactions = [_normalize_transaction(item) for item in _extract_transactions(raw_transactions)]
return {
"organization": _normalize_organization(organization),
"account": _normalize_account(selected_account),
"page": page,
"page_size": page_size,
"window_days": window_days,
"transactions": transactions,
}
def _build_snapshot_payload(self, *, window_days: int, page_size: int) -> dict[str, Any]:
accounts_payload = self._build_accounts_payload({})
accounts = accounts_payload["accounts"]
main_account = next((account for account in accounts if account["main"]), accounts[0] if accounts else None)
recent_transactions = []
if main_account is not None:
transactions_payload = self._build_transactions_payload(
account_slug=main_account["slug"],
page=1,
page_size=min(page_size, 50),
window_days=window_days,
status="completed",
side=None,
)
recent_transactions = transactions_payload["transactions"]
return {
"organization": accounts_payload["organization"],
"accounts": accounts,
"summary": {
"total_balance": accounts_payload["totals"]["balance"],
"authorized_balance": accounts_payload["totals"]["authorized_balance"],
"window_days": window_days,
},
"cost_run_rate_hints": _build_cost_run_rate_hints(recent_transactions),
"recent_transactions": recent_transactions[:10],
}
def _emit_audit(
self,
*,
request_id: str,
claims: ActorClaims,
capability_id: str,
decision: str,
deny_reason: str | None,
latency_ms: int,
result_count: int | None,
qonto_http_status: int | None,
) -> None:
event = AuditEvent(
request_id=request_id,
timestamp=utc_now_iso(),
actor=claims.actor_id,
tenant_id=claims.tenant_id,
capability=capability_id,
protocol="rest",
decision=decision,
deny_reason=deny_reason,
policy_version=self.policy.version,
latency_ms=latency_ms,
qonto_http_status=qonto_http_status,
result_count=result_count,
)
self.audit_logger.emit(event)
def _latency_ms(started: float) -> int:
return int((time.perf_counter() - started) * 1000)
def _extract_organization(payload: Mapping[str, Any]) -> Mapping[str, Any]:
organization = payload.get("organization", payload)
if not isinstance(organization, Mapping):
raise ValueError("Organization payload is missing or invalid")
return organization
def _extract_accounts(payload: Mapping[str, Any]) -> list[Mapping[str, Any]]:
organization = _extract_organization(payload)
accounts = organization.get("bank_accounts") or payload.get("bank_accounts") or []
if not isinstance(accounts, list):
raise ValueError("Bank accounts payload is invalid")
return [account for account in accounts if isinstance(account, Mapping)]
def _extract_transactions(payload: Mapping[str, Any]) -> list[Mapping[str, Any]]:
transactions = payload.get("transactions") or payload.get("items") or []
if not isinstance(transactions, list):
raise ValueError("Transactions payload is invalid")
return [transaction for transaction in transactions if isinstance(transaction, Mapping)]
def _normalize_organization(raw: Mapping[str, Any]) -> dict[str, Any]:
return {
"name": raw.get("name"),
"legal_name": raw.get("legal_name") or raw.get("legalName") or raw.get("name"),
"slug": raw.get("slug"),
"legal_country": raw.get("legal_country") or raw.get("legalCountry"),
"legal_registration_date": raw.get("legal_registration_date")
or raw.get("legalRegistrationDate"),
}
def _normalize_account(raw: Mapping[str, Any]) -> dict[str, Any]:
iban = str(raw.get("iban", ""))
return {
"name": raw.get("name"),
"slug": raw.get("slug"),
"currency": raw.get("currency", "EUR"),
"balance": _amount_value(raw),
"authorized_balance": _amount_value(raw, "authorized_balance"),
"iban_last4": raw.get("iban_last4") or (iban[-4:] if iban else None),
"main": bool(raw.get("main", False)),
"status": raw.get("status", "unknown"),
}
def _normalize_transaction(raw: Mapping[str, Any]) -> dict[str, Any]:
settled_at = raw.get("settled_at") or raw.get("settledAt") or raw.get("updated_at")
return {
"id": raw.get("id") or raw.get("transaction_id"),
"date": (str(settled_at)[:10] if settled_at else None),
"label": raw.get("label") or raw.get("counterparty_name") or raw.get("name"),
"side": raw.get("side"),
"amount": _amount_value(raw),
"currency": raw.get("currency", "EUR"),
"category": raw.get("category"),
"operation_type": raw.get("operation_type") or raw.get("operationType"),
"status": raw.get("status"),
}
def _amount_value(raw: Mapping[str, Any], key: str = "balance") -> float:
amount = raw.get(key)
if amount is None and key == "balance":
amount = raw.get("amount")
if amount is None and key == "authorized_balance":
amount = raw.get("authorized_balance")
if amount is None:
cents = raw.get("amount_cents") or raw.get("amountCents")
if cents is None:
return 0.0
return round(float(cents) / 100, 2)
return round(float(amount), 2)
def _select_account(accounts: list[Mapping[str, Any]], account_slug: str | None) -> Mapping[str, Any]:
if not accounts:
raise UpstreamError(
"No accounts available in organization payload",
error_code="qonto_invalid_payload",
status_code=502,
)
if account_slug:
for account in accounts:
if account.get("slug") == account_slug:
return account
raise InvalidRequestError(f"Unknown account slug: {account_slug}", error_code="resource_scope")
for account in accounts:
if account.get("main"):
return account
return accounts[0]
def _build_cost_run_rate_hints(transactions: list[dict[str, Any]]) -> dict[str, Any]:
recurring: dict[tuple[str, str, float], list[dict[str, Any]]] = defaultdict(list)
for transaction in transactions:
if transaction.get("side") != "debit" or transaction.get("status") != "completed":
continue
key = (
str(transaction.get("label") or "unknown"),
str(transaction.get("operation_type") or "unknown"),
float(transaction.get("amount") or 0.0),
)
recurring[key].append(transaction)
recurring_debits = []
for (label, operation_type, amount), items in recurring.items():
if len(items) < 2:
continue
observed_months = sorted(
{
datetime.fromisoformat(f"{item['date']}T00:00:00+00:00")
.astimezone(UTC)
.strftime("%Y-%m")
for item in items
if item.get("date")
}
)
recurring_debits.append(
{
"label": label,
"operation_type": operation_type,
"amount": amount,
"occurrences": len(items),
"observed_months": observed_months,
"latest_date": max(item["date"] for item in items if item.get("date")),
}
)
recurring_debits.sort(key=lambda item: (-item["amount"], item["label"]))
total_debits = round(
sum(float(transaction["amount"]) for transaction in transactions if transaction.get("side") == "debit"),
2,
)
total_credits = round(
sum(float(transaction["amount"]) for transaction in transactions if transaction.get("side") == "credit"),
2,
)
return {
"recurring_debits": recurring_debits[:10],
"total_debits": total_debits,
"total_credits": total_credits,
}
def _result_count(payload: Mapping[str, Any]) -> int | None:
for key in ("transactions", "accounts", "recent_transactions"):
value = payload.get(key)
if isinstance(value, list):
return len(value)
return None