Simpl-Resultat/docs/adr/0009-proxy-price-fetching-via-maximus-api.md
le king fu 098e15bb5c docs(adr): add ADRs 0008-0010 (Modified Dietz, proxy price-fetching, FK RESTRICT)
- 0008 — Modified Dietz: justifies the choice over ROI / TWR / IRR;
  references commands/return_calculator.rs and the 7 TDD cases.
- 0009 — Proxy price-fetching via maximus-api: documents the privacy
  proxy architecture (header stripping, no log correlation, fixed
  simpl-resultat UA), the Yahoo + CoinGecko adapter abstraction, the
  Bearer activation_token auth strategy, the rate limiting (client
  + server), and the dual-side premium gating. Implementation stays
  BLOCKED in #143; this ADR documents the agreed-upon design.
- 0010 — FK ON DELETE RESTRICT on balance_account_transfers
  .transaction_id: justifies the integrity-over-friction trade-off
  for Modified Dietz reproducibility.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:06:40 -04:00

11 KiB

ADR 0009 — Price-fetching premium via proxy maximus-api

  • Status: Accepted (architecture documentée — implémentation reportée à l'Issue #143, BLOCKED par maximus-api Phase 2)
  • Date: 2026-04-25
  • Milestone: overnight-2026-04-26-bilan (architecture spec)

Context

La feature Bilan supporte des comptes "priced" (actions, crypto) où chaque ligne de snapshot stocke (quantity, unit_price, value). La saisie manuelle de unit_price reste toujours possible mais devient pénible dès qu'on a plusieurs titres ou qu'on rétro-saisit un historique.

L'objectif est de proposer un bouton "récupérer le prix au [date]" qui interroge un fournisseur de données (Yahoo Finance, CoinGecko, etc.) sans trahir le principe privacy-first NON NÉGOCIABLE du projet :

Zéro donnée envoyée vers un serveur tiers. Tout le traitement CSV et toutes les données financières restent en local. Aucune télémétrie, aucun analytics cloud.

Or interroger Yahoo ou CoinGecko, c'est par définition envoyer une requête sortante depuis l'IP de l'utilisateur. Quelles informations fuiteraient ?

  • L'IP de l'utilisateur : géolocalisation grossière, profilage de session
  • L'User-Agent par défaut de reqwest : reqwest/0.12 ..., identifie le client comme une app Tauri (silhouette technique reconnaissable)
  • Le symbole + date : "AAPL au 2026-03-15" n'est pas identifiant en soi mais corrélé à l'IP, le provider peut reconstruire le portefeuille
  • Headers résiduels : Accept-Language peut révéler la locale système

Trois architectures candidates :

Option Privacy Complexité serveur Coût d'API
Appel direct client → provider IP exposée, fingerprint headers aucune par user (rate limits triggered fast)
Appel direct + Tor / VPN intégré ⚠ partiel, latence dégradée aucune par user
Proxy via maximus-api auto-hébergé IP cachée, headers strippés, cache mutualisé Endpoint /v1/prices à maintenir mutualisé (cache mutualisé entre users premium)

Decision

Implémenter le price-fetching comme fonctionnalité premium-only servie par maximus-api agissant comme proxy, avec consentement explicite et hygiène de headers stricte des deux côtés du fil.

Architecture

[App Tauri]
    │  GET /v1/prices?symbol=AAPL&date=2026-03-15
    │  Headers: Authorization: Bearer <activation_token>
    │           Accept: application/json
    │           User-Agent: simpl-resultat
    ▼
[maximus-api]                              ← VPS Max (Coolify)
    │  1. Strip TOUS headers entrants identifiants
    │  2. Validation tier premium (403 si non-premium)
    │  3. Cache SQLite (symbol, date) → price (TTL infini sur dates passées)
    │  4. Cache miss → adapter (Yahoo / CoinGecko)
    ▼
[Provider tiers]                           ← voit l'IP du VPS, pas du client

Choix de providers : abstraction adapter

Côté maximus-api, un module price-fetcher expose une interface unique et délègue à des adapters :

Provider Stocks Crypto Coût Adapter
Yahoo Finance (unofficial) gratuit YahooAdapter (HTTP direct)
CoinGecko excellent gratuit (free tier 30 req/min) CoinGeckoAdapter
Alpha Vantage (fallback) freemium optionnel si Yahoo casse

Stocks → Yahoo ; Crypto → CoinGecko. L'abstraction permet de swap si un provider casse, sans changer le contrat client.

Stratégie d'authentification

  • Authorization: Bearer <activation_token> uniquement. Le token est lu côté client depuis activation_path (le fichier déjà utilisé par license_commands.rs pour persister le token d'activation). Jamais stocké dans user_preferences (la table SQL de l'app n'a pas vocation à versionner les credentials).
  • Jamais en query string. Un token-in-URL leakerait dans :
    • Les logs Traefik / nginx du VPS (URL complète loguée par défaut)
    • Le header Referer si maximus-api redirige
    • Les écrans de partage (le header Authorization est masqué par les outils de capture, pas l'URL)

Hygiène des headers — privacy en profondeur

Côté client (Rust / reqwest) :

  • reqwest::Client::builder().user_agent("simpl-resultat").build() — UA fixe, pas le default reqwest/0.12 ...
  • Headers envoyés UNIQUEMENT : Authorization: Bearer <token> + Accept: application/json
  • Pas de Accept-Language (révèle la locale)
  • Pas d'autres headers identifiants

Côté serveur (maximus-api) :

  • Strip TOUS les headers entrants avant de proxyer vers le provider tiers (X-Forwarded-For, User-Agent client, Accept-Language, etc.)
  • Ne JAMAIS logger (symbol, license_id) ensemble. Soit séparer les logs (un journal pour la facturation/quota par licence sans symbole, un journal pour les hits cache/provider sans license), soit hasher le license_id côté serveur avec un sel rotatif court avant log
  • Validation premium AVANT cache et provider — un client non-premium reçoit 403 sans qu'aucun appel sortant ne soit déclenché

Rate limiting

Côté client :

  • Max 1 fetch / 2 secondes (timer simple)
  • Dedup in-flight par (symbol, date) (deux clics rapides = 1 seule requête réseau)
  • Backoff exponentiel sur 5xx / network : 2s, 4s, 8s — max 3 retries
  • Plafond hard : 100 fetches par session snapshot (anti-loop)

Côté serveur :

  • Quota par licence (proposition initiale : 1000 req/jour, le cache absorbe l'essentiel)
  • Le cache (symbol, date) est immuable pour les dates passées (TTL infini), 5 min pour today (le marché peut bouger)

Premium gating — défense en profondeur

  • UI client : si entitlements.check_entitlement("price-fetching") retourne false, le bouton ↻ affiche un tooltip "Disponible avec abonnement" et est désactivé. Pas de tentative de fetch.
  • Server-side : maximus-api /v1/prices valide le tier premium AVANT cache/provider. Un client modifié qui bypass la UI reçoit 403.

La double vérification est délibérée : le client est compromettable (l'app Tauri est ouverte au reverse-engineering), seul le serveur peut faire foi.

Consentement explicite (per-profile)

  • Stockage : user_preferences.price_fetching_consent = {consented_at: <ISO>, version: 1}
  • NE PAS seeder la clé. Absence = jamais demandé. Le default doit être "non-décidé", pas "false".
  • Premier clic sur le bouton ↻ → modal de consentement → écriture de la clé après acceptation
  • Permanence : pas de re-consent automatique. Révocation explicite via toggle Settings (supprime la clé)
  • Stockage per-profile (table user_preferences est par-profil), pas global au système

Mode offline / fallback

L'app ne doit jamais bloquer la saisie d'un snapshot parce que le price-fetching a échoué. La saisie manuelle de unit_price reste TOUJOURS disponible :

Erreur serveur Comportement
401 license expirée Toast "Renouvelez votre abonnement" + champ manuel dispo
403 non-premium Toast "Disponible avec abonnement Premium" + champ manuel dispo
404 symbole Toast "Symbole introuvable — vérifiez l'orthographe" + champ manuel
429 rate limit Toast "Limite atteinte — réessayez plus tard" + champ manuel
Network error / 5xx Toast "Service temporairement indisponible" + champ manuel

Consequences

Positive

  • L'IP de l'utilisateur n'est JAMAIS exposée à Yahoo / CoinGecko. Le provider voit l'IP du VPS de Max — privacy-first préservée.
  • Aucun symbole ne révèle de données personnelles. "AAPL" ou "BTC" ne sont pas identifiants en soi ; corrélés à une license_id ils le redeviennent, c'est pourquoi le serveur ne logue jamais les deux ensemble.
  • Cache mutualisé. Si 500 utilisateurs premium demandent AAPL au 2026-03-15, c'est UN seul appel sortant côté maximus-api. Économise les rate limits ET réduit la surface d'exposition.
  • Mode offline préservé. L'app continue de fonctionner sans price-fetching — la saisie manuelle reste le chemin de secours.
  • Justification commerciale. Le price-fetching premium aligne le coût d'API tiers sur la révenue récurrente, sans dégrader l'expérience free-tier (qui reste 100 % local).
  • Adapter pattern. Si Yahoo casse (API non officielle), swap pour Alpha Vantage côté serveur sans changer le contrat client.

Negative / trade-offs

  • Dépendance opérationnelle au VPS. Si maximus-api est down, le price-fetching ne fonctionne pas — atténué par le fallback manuel toujours dispo.
  • Surface serveur à maintenir. Endpoint /v1/prices + cache + adapters + auth + rate limiting + observabilité (sans corrélation log).
  • Charge financière sur Max. Les tier free n'ont pas accès, donc les coûts d'API tiers sont absorbés par les abonnements premium ; le cache aide significativement.
  • Implémentation BLOQUÉE. L'Issue #143 ne peut shipper tant que maximus-api Phase 2 n'expose pas /v1/prices (dépendance externe : issues maximus-api #49 license server core et #136 Stripe webhooks).

Alternatives considered

  • Appel direct client → provider. Rejeté : viole le principe privacy-first (IP exposée + fingerprint headers).
  • Tor / I2P intégré. Rejeté : latence prohibitive (5-10 secondes par fetch), maintenance d'un client Tor embarqué dans Tauri, et certains providers bloquent les exits Tor.
  • VPN tiers (Mullvad, etc.) configuré par l'utilisateur. Rejeté : ne supprime pas le fingerprint headers, et "exiger l'utilisateur à configurer un VPN" est une régression UX inacceptable.
  • Cache local sans serveur (chaque user a son propre cache). Rejeté : pas de mutualisation, chaque user paie son propre rate limit, et le client doit toujours faire l'appel sortant initial (donc IP exposée).
  • Saisie manuelle uniquement, pas de price-fetching du tout. C'est le mode free-tier — fonctionnel mais friction élevée pour les utilisateurs avec un portefeuille actions/crypto significatif. Le proxy premium est le compromis qui justifie l'abonnement sans dégrader le free-tier.
  • Endpoint /v1/symbols/search côté maximus-api pour autocomplete. Reporté à v2 : l'autocomplete double la surface d'API et n'est pas critique. La saisie texte simple suffit au MVP.

References

  • Spec : spec-decisions-bilan.md, spec-plan-bilan.md (Issue #5 — Phase 5)
  • Spike : ~/claude-code/.spikes/bilan/code/price-fetching.md (architecture, choix providers, consent flow)
  • Issue client (BLOCKED) : maximus/simpl-resultat #143
  • Issues maximus-api (externes, prerequisites) : maximus-api#49 (license server core), maximus-api#136 (Stripe webhooks)
  • Pattern auth : src-tauri/src/commands/license_commands.rs (activation_path + activate_machine — le token Bearer existe déjà)
  • Privacy frame : ce que maximus-api voit jamais ensemble = (IP, license_id, symbol). Le proxy garantit que (IP) est cachée du provider et que (license_id, symbol) ne se retrouvent pas dans le même log.