feat(audit): publish sequenced heartbeat and reconciliation evidence
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a06ec5-7e2b-7743-ac08-719e1b0f42e2
This commit is contained in:
parent
fce60099c4
commit
b9349782f4
32 changed files with 839 additions and 111 deletions
|
|
@ -1,8 +1,9 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from contextlib import AsyncExitStack, asynccontextmanager, suppress
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import AsyncExitStack, asynccontextmanager, suppress
|
||||
from uuid import uuid4
|
||||
|
||||
import uvicorn
|
||||
|
|
@ -39,13 +40,18 @@ def create_app(
|
|||
key_cape_verifier: KeyCapeTokenVerifier | None = None,
|
||||
) -> FastAPI:
|
||||
settings = settings or Settings.from_env()
|
||||
audit_logger = audit_logger or AuditLogger()
|
||||
service = service or _build_service(
|
||||
settings=settings,
|
||||
audit_logger=audit_logger,
|
||||
rate_limiter=rate_limiter,
|
||||
concurrency_limiter=concurrency_limiter,
|
||||
)
|
||||
if service is not None:
|
||||
if audit_logger is not None and audit_logger is not service.audit_logger:
|
||||
raise ValueError("service and app must share one audit_logger")
|
||||
audit_logger = service.audit_logger
|
||||
else:
|
||||
audit_logger = audit_logger or AuditLogger()
|
||||
service = _build_service(
|
||||
settings=settings,
|
||||
audit_logger=audit_logger,
|
||||
rate_limiter=rate_limiter,
|
||||
concurrency_limiter=concurrency_limiter,
|
||||
)
|
||||
if key_cape_verifier is None and settings.key_cape_jwks_url:
|
||||
key_cape_verifier = KeyCapeTokenVerifier(
|
||||
jwks_url=settings.key_cape_jwks_url,
|
||||
|
|
@ -57,7 +63,9 @@ def create_app(
|
|||
cache_seconds=settings.key_cape_cache_seconds,
|
||||
)
|
||||
|
||||
mcp_server = create_mcp_server(settings=settings, service=service, key_cape_verifier=key_cape_verifier)
|
||||
mcp_server = create_mcp_server(
|
||||
settings=settings, service=service, key_cape_verifier=key_cape_verifier
|
||||
)
|
||||
mcp_app = mcp_server.streamable_http_app()
|
||||
if settings.mcp_auth_token:
|
||||
mcp_app.add_middleware(BearerTokenAuthMiddleware, token=settings.mcp_auth_token)
|
||||
|
|
@ -66,9 +74,24 @@ def create_app(
|
|||
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
async with AsyncExitStack() as stack:
|
||||
await stack.enter_async_context(mcp_app.router.lifespan_context(mcp_app))
|
||||
yield
|
||||
with suppress(Exception):
|
||||
service.client.close()
|
||||
audit_logger.emit_heartbeat(reason="startup")
|
||||
heartbeat_task = asyncio.create_task(
|
||||
_emit_audit_heartbeats(
|
||||
audit_logger=audit_logger,
|
||||
interval_seconds=settings.audit_heartbeat_interval_seconds,
|
||||
)
|
||||
)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
heartbeat_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await heartbeat_task
|
||||
audit_logger.emit_heartbeat(reason="shutdown")
|
||||
finally:
|
||||
with suppress(Exception):
|
||||
service.client.close()
|
||||
|
||||
app = FastAPI(title="qonto-assistant", version=__version__, lifespan=lifespan)
|
||||
app.state.settings = settings
|
||||
|
|
@ -91,11 +114,21 @@ def create_app(
|
|||
"policy_file": str(settings.policy_file),
|
||||
}
|
||||
|
||||
@app.get("/v1/audit/reconciliation")
|
||||
async def audit_reconciliation(request: Request) -> dict[str, object]:
|
||||
# Apply the same deployment identity boundary as finance calls. The
|
||||
# view contains counts only and deliberately does not create another
|
||||
# transition in the stream it is describing.
|
||||
actor_claims_from_request(request, settings, key_cape_verifier=key_cape_verifier)
|
||||
return audit_logger.reconciliation_snapshot()
|
||||
|
||||
@app.get("/v1/accounts")
|
||||
async def get_accounts(request: Request) -> JSONResponse:
|
||||
claims = actor_claims_from_request(request, settings, key_cape_verifier=key_cape_verifier)
|
||||
request_id = _request_id(request)
|
||||
payload = await run_in_threadpool(service.get_accounts, claims=claims, request_id=request_id)
|
||||
payload = await run_in_threadpool(
|
||||
service.get_accounts, claims=claims, request_id=request_id
|
||||
)
|
||||
return JSONResponse(content=payload, headers={"X-Request-ID": request_id})
|
||||
|
||||
@app.get("/v1/transactions")
|
||||
|
|
@ -143,6 +176,12 @@ def create_app(
|
|||
return app
|
||||
|
||||
|
||||
async def _emit_audit_heartbeats(*, audit_logger: AuditLogger, interval_seconds: int) -> None:
|
||||
while True:
|
||||
await asyncio.sleep(interval_seconds)
|
||||
audit_logger.emit_heartbeat(reason="periodic")
|
||||
|
||||
|
||||
def _request_id(request: Request) -> str:
|
||||
existing = getattr(request.state, "request_id", None)
|
||||
if existing:
|
||||
|
|
|
|||
|
|
@ -3,9 +3,12 @@ from __future__ import annotations
|
|||
import json
|
||||
import logging
|
||||
from collections.abc import Callable, Mapping
|
||||
from copy import deepcopy
|
||||
from dataclasses import asdict, is_dataclass
|
||||
from datetime import UTC, datetime
|
||||
from threading import RLock
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from qonto_assistant.contracts import AuditEvent
|
||||
|
||||
|
|
@ -18,6 +21,8 @@ SECRET_KEYS = {
|
|||
"secret",
|
||||
"token",
|
||||
}
|
||||
AUDIT_STREAM_ID = "qonto-assistant.audit"
|
||||
AUDIT_EVENT_CLASSES = ("audit.allow", "audit.deny")
|
||||
|
||||
|
||||
def utc_now_iso() -> str:
|
||||
|
|
@ -40,19 +45,107 @@ def _sanitize(value: Any) -> Any:
|
|||
|
||||
|
||||
class AuditLogger:
|
||||
"""Emit sequenced audit records and source-side reconciliation evidence.
|
||||
|
||||
Counters represent source transitions, not observer delivery receipts. An
|
||||
observer compares these counters with received ``audit.allow`` and
|
||||
``audit.deny`` records and also checks ``stream_sequence`` for gaps.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
logger: logging.Logger | None = None,
|
||||
sink: Callable[[dict[str, Any]], None] | None = None,
|
||||
instance_id: str | None = None,
|
||||
clock: Callable[[], str] = utc_now_iso,
|
||||
) -> None:
|
||||
self.logger = logger or logging.getLogger("qonto_assistant.audit")
|
||||
self.logger.setLevel(logging.INFO)
|
||||
self.sink = sink
|
||||
self.instance_id = instance_id or str(uuid4())
|
||||
self.clock = clock
|
||||
self.started_at = clock()
|
||||
self._lock = RLock()
|
||||
self._sequence = 0
|
||||
self._transition_counts = {event_class: 0 for event_class in AUDIT_EVENT_CLASSES}
|
||||
self._heartbeat_counts = dict(self._transition_counts)
|
||||
self._heartbeat_at = self.started_at
|
||||
|
||||
def emit(self, event: AuditEvent | Mapping[str, Any]) -> dict[str, Any]:
|
||||
payload = _sanitize(asdict(event) if is_dataclass(event) else dict(event))
|
||||
if self.sink is not None:
|
||||
self.sink(payload)
|
||||
decision = payload.get("decision")
|
||||
event_class = f"audit.{decision}" if decision in {"allow", "deny"} else "audit.event"
|
||||
|
||||
with self._lock:
|
||||
self._sequence += 1
|
||||
if event_class in self._transition_counts:
|
||||
self._transition_counts[event_class] += 1
|
||||
payload.update(self._stream_fields(event_class=event_class, sequence=self._sequence))
|
||||
self._publish(payload)
|
||||
|
||||
return deepcopy(payload)
|
||||
|
||||
def emit_heartbeat(self, *, reason: str = "periodic") -> dict[str, Any]:
|
||||
"""Emit a positive liveness claim plus counts for observer reconciliation."""
|
||||
with self._lock:
|
||||
now = self.clock()
|
||||
self._sequence += 1
|
||||
window_counts = {
|
||||
event_class: self._transition_counts[event_class]
|
||||
- self._heartbeat_counts[event_class]
|
||||
for event_class in AUDIT_EVENT_CLASSES
|
||||
}
|
||||
payload: dict[str, Any] = {
|
||||
**self._stream_fields(event_class="audit.heartbeat", sequence=self._sequence),
|
||||
"timestamp": now,
|
||||
"reason": reason,
|
||||
"assertion": (
|
||||
"nothing-to-report"
|
||||
if window_counts["audit.deny"] == 0
|
||||
else "transitions-reported"
|
||||
),
|
||||
"window_started_at": self._heartbeat_at,
|
||||
"window_ended_at": now,
|
||||
"source_transition_counts": dict(self._transition_counts),
|
||||
"window_transition_counts": window_counts,
|
||||
}
|
||||
self._heartbeat_counts = dict(self._transition_counts)
|
||||
self._heartbeat_at = now
|
||||
self._publish(payload)
|
||||
|
||||
return deepcopy(payload)
|
||||
|
||||
def reconciliation_snapshot(self) -> dict[str, Any]:
|
||||
"""Return non-secret source state without adding an audit-stream record."""
|
||||
with self._lock:
|
||||
now = self.clock()
|
||||
return {
|
||||
"stream_id": AUDIT_STREAM_ID,
|
||||
"stream_instance_id": self.instance_id,
|
||||
"instance_started_at": self.started_at,
|
||||
"snapshot_at": now,
|
||||
"last_stream_sequence": self._sequence,
|
||||
"last_heartbeat_at": self._heartbeat_at,
|
||||
"source_transition_counts": dict(self._transition_counts),
|
||||
"since_last_heartbeat_counts": {
|
||||
event_class: self._transition_counts[event_class]
|
||||
- self._heartbeat_counts[event_class]
|
||||
for event_class in AUDIT_EVENT_CLASSES
|
||||
},
|
||||
}
|
||||
|
||||
def _stream_fields(self, *, event_class: str, sequence: int) -> dict[str, Any]:
|
||||
return {
|
||||
"event_class": event_class,
|
||||
"stream_id": AUDIT_STREAM_ID,
|
||||
"stream_instance_id": self.instance_id,
|
||||
"stream_sequence": sequence,
|
||||
}
|
||||
|
||||
def _publish(self, payload: dict[str, Any]) -> None:
|
||||
# Structured logging is the production hot-path sink. Publish it first
|
||||
# so an optional secondary sink cannot suppress the primary record.
|
||||
self.logger.info(json.dumps(payload, sort_keys=True))
|
||||
return payload
|
||||
if self.sink is not None:
|
||||
self.sink(deepcopy(payload))
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ class Settings:
|
|||
deny_escalation_threshold: int
|
||||
deny_escalation_window_seconds: int
|
||||
deny_escalation_lockout_seconds: int
|
||||
audit_heartbeat_interval_seconds: int
|
||||
key_cape_jwks_url: str | None
|
||||
key_cape_issuer: str
|
||||
key_cape_audience: str
|
||||
|
|
@ -81,10 +82,18 @@ class Settings:
|
|||
rate_limit_requests=int(os.getenv("QONTO_RATE_LIMIT_REQUESTS", "20")),
|
||||
rate_limit_window_seconds=int(os.getenv("QONTO_RATE_LIMIT_WINDOW_SECONDS", "60")),
|
||||
max_concurrency=int(os.getenv("QONTO_MAX_CONCURRENCY", "4")),
|
||||
deny_escalation_enabled=os.getenv("QONTO_DENY_ESCALATION_ENABLED", "true").lower() == "true",
|
||||
deny_escalation_enabled=os.getenv("QONTO_DENY_ESCALATION_ENABLED", "true").lower()
|
||||
== "true",
|
||||
deny_escalation_threshold=int(os.getenv("QONTO_DENY_ESCALATION_THRESHOLD", "3")),
|
||||
deny_escalation_window_seconds=int(os.getenv("QONTO_DENY_ESCALATION_WINDOW_SECONDS", "60")),
|
||||
deny_escalation_lockout_seconds=int(os.getenv("QONTO_DENY_ESCALATION_LOCKOUT_SECONDS", "300")),
|
||||
deny_escalation_window_seconds=int(
|
||||
os.getenv("QONTO_DENY_ESCALATION_WINDOW_SECONDS", "60")
|
||||
),
|
||||
deny_escalation_lockout_seconds=int(
|
||||
os.getenv("QONTO_DENY_ESCALATION_LOCKOUT_SECONDS", "300")
|
||||
),
|
||||
audit_heartbeat_interval_seconds=max(
|
||||
1, int(os.getenv("QONTO_AUDIT_HEARTBEAT_INTERVAL_SECONDS", "86400"))
|
||||
),
|
||||
key_cape_jwks_url=os.getenv("QONTO_KEY_CAPE_JWKS_URL") or None,
|
||||
key_cape_issuer=os.getenv("QONTO_KEY_CAPE_ISSUER", "https://key-cape.netkingdom"),
|
||||
key_cape_audience=os.getenv("QONTO_KEY_CAPE_AUDIENCE", "qonto-assistant"),
|
||||
|
|
@ -94,7 +103,9 @@ class Settings:
|
|||
flex_auth_base_url=os.getenv("QONTO_FLEX_AUTH_URL") or None,
|
||||
flex_auth_timeout_seconds=float(os.getenv("QONTO_FLEX_AUTH_TIMEOUT_SECONDS", "3")),
|
||||
tenant_engine_base_url=os.getenv("QONTO_TENANT_ENGINE_URL") or None,
|
||||
tenant_engine_timeout_seconds=float(os.getenv("QONTO_TENANT_ENGINE_TIMEOUT_SECONDS", "3")),
|
||||
tenant_engine_timeout_seconds=float(
|
||||
os.getenv("QONTO_TENANT_ENGINE_TIMEOUT_SECONDS", "3")
|
||||
),
|
||||
tenant_engine_required_roles=frozenset(
|
||||
role.strip()
|
||||
for role in os.getenv("QONTO_TENANT_ENGINE_REQUIRED_ROLES", "VEN,CUS").split(",")
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ class ActorClaims:
|
|||
tenant_id: str
|
||||
lane: str = "green"
|
||||
scopes: frozenset[str] = field(default_factory=frozenset)
|
||||
identity_binding: str = "self_asserted"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -52,5 +53,7 @@ class AuditEvent:
|
|||
deny_reason: str | None
|
||||
policy_version: int
|
||||
latency_ms: int
|
||||
identity_binding: str
|
||||
egress_destination: str
|
||||
qonto_http_status: int | None = None
|
||||
result_count: int | None = None
|
||||
|
|
|
|||
|
|
@ -74,7 +74,9 @@ class OpenBaoCliCredentialProvider:
|
|||
return value
|
||||
|
||||
|
||||
def build_credential_provider(settings: Settings) -> EnvironmentCredentialProvider | OpenBaoCliCredentialProvider:
|
||||
def build_credential_provider(
|
||||
settings: Settings,
|
||||
) -> EnvironmentCredentialProvider | OpenBaoCliCredentialProvider:
|
||||
if settings.credential_source == "bao-cli":
|
||||
return OpenBaoCliCredentialProvider(
|
||||
command=settings.openbao_command,
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ class KeyCapeTokenVerifier:
|
|||
tenant_id=str(claims["tenant"]),
|
||||
lane=_lane_from_roles(claims.get("roles"), default=self.default_lane),
|
||||
scopes=frozenset(_coerce_scopes(claims.get("scope") or claims.get("scp"))),
|
||||
identity_binding="key_cape_jwt",
|
||||
)
|
||||
|
||||
def _ensure_keys(self) -> None:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
from qonto_assistant.app import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ def create_mcp_server(
|
|||
window_days: int = 90,
|
||||
page_size: int = 50,
|
||||
) -> dict[str, Any]:
|
||||
"""Normalized recurring-cost hints, not a raw export (cost_run_rate_hints capability)."""
|
||||
"""Normalized recurring-cost hints for the cost_run_rate_hints capability."""
|
||||
claims = _claims(ctx, settings, key_cape_verifier)
|
||||
return service.get_cost_run_rate_hints(
|
||||
claims=claims,
|
||||
|
|
@ -103,7 +103,9 @@ def mcp_asgi_app(*, settings: Settings, service: CapabilityService | None = None
|
|||
return create_mcp_server(settings=settings, service=service).streamable_http_app()
|
||||
|
||||
|
||||
def _claims(ctx: Context, settings: Settings, key_cape_verifier: KeyCapeTokenVerifier | None = None):
|
||||
def _claims(
|
||||
ctx: Context, settings: Settings, key_cape_verifier: KeyCapeTokenVerifier | None = None
|
||||
):
|
||||
headers = _headers_from_context(ctx)
|
||||
return actor_claims_from_headers(headers, settings, key_cape_verifier=key_cape_verifier)
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ from qonto_assistant.contracts import CapabilityRequest, PolicyDecision
|
|||
|
||||
|
||||
class PolicyEngine:
|
||||
def __init__(self, config: Mapping[str, Any], *, required_scope: str, enforce_scope: bool) -> None:
|
||||
def __init__(
|
||||
self, config: Mapping[str, Any], *, required_scope: str, enforce_scope: bool
|
||||
) -> None:
|
||||
self.config = dict(config)
|
||||
self.version = int(self.config.get("version", 1))
|
||||
self.required_scope = required_scope
|
||||
|
|
@ -54,7 +56,9 @@ class PolicyEngine:
|
|||
request_args=dict(request.request_args),
|
||||
)
|
||||
|
||||
def _constraints_ok(self, capability: Mapping[str, Any], request_args: Mapping[str, Any]) -> bool:
|
||||
def _constraints_ok(
|
||||
self, capability: Mapping[str, Any], request_args: Mapping[str, Any]
|
||||
) -> bool:
|
||||
constraints = dict(capability.get("constraints", {}))
|
||||
checks = {
|
||||
"page_size": ("max_per_page", 1),
|
||||
|
|
@ -78,9 +82,14 @@ class PolicyEngine:
|
|||
def _match_deny_classes(self, request: CapabilityRequest) -> str | None:
|
||||
spend_prefixes = tuple(self.deny_classes.get("spend", {}).get("match_prefixes", []))
|
||||
volume_tags = tuple(self.deny_classes.get("volume_cost", {}).get("match_tags", []))
|
||||
credential_fields = set(self.deny_classes.get("credential_exfil", {}).get("response_fields", []))
|
||||
credential_fields = set(
|
||||
self.deny_classes.get("credential_exfil", {}).get("response_fields", [])
|
||||
)
|
||||
|
||||
lowered_tokens = {token.lower() for token in self._flatten_strings(request.capability_id, request.request_args)}
|
||||
lowered_tokens = {
|
||||
token.lower()
|
||||
for token in self._flatten_strings(request.capability_id, request.request_args)
|
||||
}
|
||||
for prefix in spend_prefixes:
|
||||
lowered_prefix = prefix.lower()
|
||||
if any(token.startswith(lowered_prefix) for token in lowered_tokens):
|
||||
|
|
@ -100,11 +109,15 @@ class PolicyEngine:
|
|||
if any(str(field).lower() in credential_fields for field in requested_fields):
|
||||
return "credential_exfil"
|
||||
|
||||
if request.request_args.get("include_full_iban") or request.request_args.get("include_api_key"):
|
||||
if request.request_args.get("include_full_iban") or request.request_args.get(
|
||||
"include_api_key"
|
||||
):
|
||||
return "credential_exfil"
|
||||
|
||||
amount_keys = {"amount", "amount_cents", "amount_eur"}
|
||||
if any(key in request.request_args for key in amount_keys) and request.request_args.get("execute"):
|
||||
if any(key in request.request_args for key in amount_keys) and request.request_args.get(
|
||||
"execute"
|
||||
):
|
||||
return "spend"
|
||||
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -4,8 +4,7 @@ import json
|
|||
from collections.abc import Mapping
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Protocol
|
||||
from typing import Any, Protocol
|
||||
|
||||
import httpx
|
||||
|
||||
|
|
@ -101,7 +100,9 @@ class QontoClient:
|
|||
if attempt < self.max_retries:
|
||||
attempt += 1
|
||||
continue
|
||||
raise UpstreamError("Qonto request timed out", error_code="qonto_timeout", status_code=504) from exc
|
||||
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
|
||||
|
|
@ -179,7 +180,9 @@ class FixtureQontoClient:
|
|||
status_code=500,
|
||||
)
|
||||
|
||||
filtered: list[Mapping[str, Any]] = [item for item in transactions if isinstance(item, Mapping)]
|
||||
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:
|
||||
|
|
@ -221,14 +224,18 @@ class FixtureQontoClient:
|
|||
)
|
||||
return payload
|
||||
|
||||
def _filter_window(self, transactions: list[Mapping[str, Any]], window_days: int) -> list[Mapping[str, Any]]:
|
||||
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))
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -7,7 +7,13 @@ 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, PolicyDecision, ProtocolName
|
||||
from qonto_assistant.contracts import (
|
||||
ActorClaims,
|
||||
AuditEvent,
|
||||
CapabilityRequest,
|
||||
PolicyDecision,
|
||||
ProtocolName,
|
||||
)
|
||||
from qonto_assistant.errors import InvalidRequestError, PolicyDeniedError, UpstreamError
|
||||
from qonto_assistant.live_authorization import LiveAuthorizationGate
|
||||
from qonto_assistant.policy import PolicyEngine
|
||||
|
|
@ -102,7 +108,9 @@ class CapabilityService:
|
|||
resource_scope="snapshot",
|
||||
request_id=request_id,
|
||||
protocol=protocol,
|
||||
operation=lambda _: self._build_snapshot_payload(window_days=window_days, page_size=page_size),
|
||||
operation=lambda _: self._build_snapshot_payload(
|
||||
window_days=window_days, page_size=page_size
|
||||
),
|
||||
)
|
||||
|
||||
def get_cost_run_rate_hints(
|
||||
|
|
@ -227,13 +235,17 @@ class CapabilityService:
|
|||
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)]
|
||||
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),
|
||||
"authorized_balance": round(
|
||||
sum(account["authorized_balance"] for account in accounts), 2
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -259,7 +271,9 @@ class CapabilityService:
|
|||
status=status,
|
||||
side=side,
|
||||
)
|
||||
transactions = [_normalize_transaction(item) for item in _extract_transactions(raw_transactions)]
|
||||
transactions = [
|
||||
_normalize_transaction(item) for item in _extract_transactions(raw_transactions)
|
||||
]
|
||||
return {
|
||||
"organization": _normalize_organization(organization),
|
||||
"account": _normalize_account(selected_account),
|
||||
|
|
@ -272,7 +286,9 @@ class CapabilityService:
|
|||
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)
|
||||
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(
|
||||
|
|
@ -297,10 +313,14 @@ class CapabilityService:
|
|||
"recent_transactions": recent_transactions[:10],
|
||||
}
|
||||
|
||||
def _build_cost_run_rate_hints_payload(self, *, window_days: int, page_size: int) -> dict[str, Any]:
|
||||
def _build_cost_run_rate_hints_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)
|
||||
main_account = next(
|
||||
(account for account in accounts if account["main"]), accounts[0] if accounts else None
|
||||
)
|
||||
recent_transactions: list[dict[str, Any]] = []
|
||||
if main_account is not None:
|
||||
transactions_payload = self._build_transactions_payload(
|
||||
|
|
@ -343,6 +363,8 @@ class CapabilityService:
|
|||
deny_reason=deny_reason,
|
||||
policy_version=self.policy.version,
|
||||
latency_ms=latency_ms,
|
||||
identity_binding=claims.identity_binding,
|
||||
egress_destination="qonto-thirdparty-api",
|
||||
qonto_http_status=qonto_http_status,
|
||||
result_count=result_count,
|
||||
)
|
||||
|
|
@ -429,7 +451,9 @@ def _amount_value(raw: Mapping[str, Any], key: str = "balance") -> float:
|
|||
return round(float(amount), 2)
|
||||
|
||||
|
||||
def _select_account(accounts: list[Mapping[str, Any]], account_slug: str | None) -> Mapping[str, Any]:
|
||||
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",
|
||||
|
|
@ -440,7 +464,9 @@ def _select_account(accounts: list[Mapping[str, Any]], account_slug: str | None)
|
|||
for account in accounts:
|
||||
if account.get("slug") == account_slug:
|
||||
return account
|
||||
raise InvalidRequestError(f"Unknown account slug: {account_slug}", error_code="resource_scope")
|
||||
raise InvalidRequestError(
|
||||
f"Unknown account slug: {account_slug}", error_code="resource_scope"
|
||||
)
|
||||
for account in accounts:
|
||||
if account.get("main"):
|
||||
return account
|
||||
|
|
@ -485,11 +511,19 @@ def _build_cost_run_rate_hints(transactions: list[dict[str, Any]]) -> dict[str,
|
|||
|
||||
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"),
|
||||
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"),
|
||||
sum(
|
||||
float(transaction["amount"])
|
||||
for transaction in transactions
|
||||
if transaction.get("side") == "credit"
|
||||
),
|
||||
2,
|
||||
)
|
||||
return {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue