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>
403 lines
14 KiB
Python
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
|