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
403
src/qonto_assistant/service.py
Normal file
403
src/qonto_assistant/service.py
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue