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