- 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>
158 lines
11 KiB
Markdown
158 lines
11 KiB
Markdown
# 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-decisions-bilan.md), [`spec-plan-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.
|