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>
This commit is contained in:
parent
4d5a0e2e3b
commit
098e15bb5c
3 changed files with 343 additions and 0 deletions
100
docs/adr/0008-modified-dietz-pour-rendement.md
Normal file
100
docs/adr/0008-modified-dietz-pour-rendement.md
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
# ADR 0008 — Modified Dietz pour le calcul du rendement par compte
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-04-25
|
||||
- Milestone: `overnight-2026-04-26-bilan` (Issues #138 → #145)
|
||||
|
||||
## Context
|
||||
|
||||
La feature Bilan introduit une vue patrimoniale (snapshots datés) avec calcul du rendement par compte. Le rendement réel d'un compte d'investissement n'est PAS `(V_fin − V_début) / V_début` : cette formule confond les **gains réels** avec les **apports/retraits**.
|
||||
|
||||
> Exemple : compte CELI à 10 000 $, on dépose 5 000 $, le compte vaut 16 000 $ à la fin. La formule naïve donne 60 % (16/10), mais la moitié du gain est juste l'apport. Le vrai rendement est 6 % : `(16 000 − 5 000 − 10 000) / 10 000`.
|
||||
|
||||
C'est exactement la raison pour laquelle l'utilisateur tague des transferts (table `balance_account_transfers`) : pour les exclure du calcul.
|
||||
|
||||
Quatre formules candidates ont été comparées dans le spike (`~/claude-code/.spikes/bilan/code/rendement.md`) :
|
||||
|
||||
| Méthode | Pondère le timing des flux ? | Nécessite des valeurs intermédiaires ? | Standard d'industrie ? |
|
||||
|---------|---|---|---|
|
||||
| ROI ajusté simple | ❌ | ❌ | ❌ |
|
||||
| **Modified Dietz** | ✅ (approximation linéaire) | ❌ | ✅ (GIPS-compliant en première approximation) |
|
||||
| Time-Weighted Return (TWR) | ✅ (exact) | ✅ (à chaque flux) | ✅ |
|
||||
| Money-Weighted Return / IRR | ✅ (exact, itératif) | ❌ | ✅ |
|
||||
|
||||
Contraintes du contexte Simpl'Résultat :
|
||||
- Les snapshots sont saisis librement (mensuels, trimestriels, ad-hoc) — il n'y a **pas** de valeur du compte aux dates de flux.
|
||||
- Pas de solveur numérique embarqué côté client (pas de Newton-Raphson en Rust pour l'IRR).
|
||||
- L'utilisateur doit pouvoir comprendre le résultat sans formation financière.
|
||||
|
||||
## Decision
|
||||
|
||||
**Adopter Modified Dietz** comme méthode unique de calcul du rendement par compte au MVP, implémentée côté Rust dans le module privé `src-tauri/src/commands/return_calculator.rs`.
|
||||
|
||||
```
|
||||
R = (V_fin − V_début − C_net) / (V_début + Σ(C_i × W_i))
|
||||
```
|
||||
|
||||
où :
|
||||
- `C_i` = chaque flux (signé : + apport, − retrait)
|
||||
- `W_i = (T − t_i) / T` = poids temporel (1 si début de période, 0 si fin)
|
||||
- `T` = durée totale de la période en jours, `t_i` = position du flux
|
||||
|
||||
### Architecture
|
||||
|
||||
- **Logique pure** : `commands/return_calculator.rs` (module privé, pas exposé comme commande). `pub(crate) fn modified_dietz(...) -> AccountReturn`.
|
||||
- **Commande Tauri** : `commands/balance_commands.rs::compute_account_return(account_id, period_start, period_end, db_filename)` ouvre une connexion `rusqlite` courte sur la DB du profil actif, lit le snapshot ≤ start, le snapshot ≥ end et les cash flows liés, puis délègue le calcul.
|
||||
- **Dépendance Cargo** : `chrono = "0.4"` ajoutée pour l'arithmétique de dates (poids temporels en jours).
|
||||
- **Tests TDD co-localisés** : `#[cfg(test)] mod tests` dans le même fichier — 7 cas (nominal, pas de snapshot début, partial-end, compte créé en cours, compte vidé, aucun transfert, annualisation).
|
||||
|
||||
### Output
|
||||
|
||||
```rust
|
||||
struct AccountReturn {
|
||||
value_start: Option<f64>,
|
||||
value_end: f64,
|
||||
net_contributions: f64,
|
||||
return_pct: Option<f64>, // None si dénominateur ≈ 0
|
||||
annualized_pct: Option<f64>, // (1 + R)^(365/days) - 1, si days > 30
|
||||
is_partial: bool, // true si snapshot manquant après fin
|
||||
has_no_transfers_warning: bool, // true si aucun transfert lié
|
||||
}
|
||||
```
|
||||
|
||||
### Affichage côté UI (`BalanceAccountsTable`)
|
||||
|
||||
- 3 colonnes Modified Dietz : 3M / 1A / depuis création
|
||||
- 1 colonne **rendement non-ajusté** (`(V_fin − V_début) / V_début`) côte-à-côte — pédagogique : montre l'effet des apports vs gains réels
|
||||
- Warnings visibles (`is_partial`, `has_no_transfers_warning`) avec tooltip i18n
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Pas besoin de valeurs intermédiaires** : le calcul ne nécessite que les snapshots existants + les transferts taggés. C'est exactement ce que l'utilisateur saisit déjà.
|
||||
- **Standard d'industrie** : Modified Dietz est GIPS-compliant en première approximation. Le résultat est défendable.
|
||||
- **Pédagogique** : afficher le rendement non-ajusté à côté du Modified Dietz éduque l'utilisateur sur la différence entre "valeur du compte" et "vraie performance".
|
||||
- **Implémentation simple** : ~50 lignes de logique pure en Rust + 7 tests. Pas de solveur numérique.
|
||||
- **Reproductibilité** : combinée avec la FK `ON DELETE RESTRICT` sur `balance_account_transfers.transaction_id` (voir [ADR 0010](0010-fk-restrict-balance-transfers.md)), une période déjà calculée ne peut pas changer rétroactivement.
|
||||
|
||||
### Negative / trade-offs
|
||||
|
||||
- **Approximation** : Modified Dietz suppose une distribution linéaire des flux dans le temps. Si plusieurs flux concentrés tombent juste avant un mouvement de marché significatif, l'erreur s'accumule. Acceptable pour un usage personnel ; un investisseur professionnel utiliserait TWR exact.
|
||||
- **Cas dégénéré "compte vidé puis rechargé"** : le dénominateur `V_début + Σ(C_i × W_i)` peut tendre vers zéro et faire exploser le ratio. Mitigé par un warning UI "Performance non significative" basé sur `has_no_transfers_warning` ou un seuil sur le dénominateur.
|
||||
- **Pas de TWR au MVP** : si l'utilisateur veut la vraie performance gestionnaire (indépendante du timing des flux), il devra attendre une v2 qui demandera de saisir des valeurs intermédiaires aux dates de flux.
|
||||
- **Pas de Money-Weighted Return / IRR** : formule plus précise mais nécessite Newton-Raphson. Coût/bénéfice défavorable au MVP.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **ROI ajusté simple** (`(V_fin − V_début − C_net) / V_début`). Rejeté : ignore *quand* l'apport est arrivé. Un dépôt de 10 000 $ le 1er janvier vs le 31 décembre donne le même résultat — incorrect.
|
||||
- **TWR (Time-Weighted Return)**. Rejeté pour le MVP : nécessite des valeurs du compte aux dates de flux, qu'on ne stocke pas. Possible v2 si l'utilisateur accepte de saisir des valeurs intermédiaires.
|
||||
- **IRR (Money-Weighted Return)**. Rejeté : nécessite un solveur Newton-Raphson, complexité disproportionnée pour un usage personnel.
|
||||
- **Calcul côté TypeScript (sans commande Rust)**. Rejeté : l'arithmétique de dates en JavaScript (`Date.UTC(...) / 86400000`) est correcte mais le pattern projet (logique financière côté Rust avec tests `cargo`) est plus robuste. Cohérent avec `aes-gcm`, `argon2`, etc.
|
||||
|
||||
## References
|
||||
|
||||
- Spec : [`spec-decisions-bilan.md`](../../spec-decisions-bilan.md), [`spec-plan-bilan.md`](../../spec-plan-bilan.md)
|
||||
- Spike : `~/claude-code/.spikes/bilan/code/rendement.md` (comparaison ROI / Modified Dietz / TWR / IRR)
|
||||
- Implémentation : `src-tauri/src/commands/return_calculator.rs`, `src-tauri/src/commands/balance_commands.rs`
|
||||
- Tests TDD : `#[cfg(test)] mod tests` dans `return_calculator.rs` (7 cas)
|
||||
- ADR liée : [0010 — FK RESTRICT sur `balance_account_transfers.transaction_id`](0010-fk-restrict-balance-transfers.md)
|
||||
- GIPS standards (Global Investment Performance Standards) — Modified Dietz est listé comme méthode acceptable d'approximation pour des périodes < 1 an.
|
||||
158
docs/adr/0009-proxy-price-fetching-via-maximus-api.md
Normal file
158
docs/adr/0009-proxy-price-fetching-via-maximus-api.md
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
# 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.
|
||||
85
docs/adr/0010-fk-restrict-balance-transfers.md
Normal file
85
docs/adr/0010-fk-restrict-balance-transfers.md
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
# ADR 0010 — `ON DELETE RESTRICT` sur `balance_account_transfers.transaction_id`
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-04-25
|
||||
- Milestone: `overnight-2026-04-26-bilan` (Issues #138 → #145)
|
||||
|
||||
## Context
|
||||
|
||||
La table `balance_account_transfers` lie une `transaction` existante à un `balance_account` avec une direction (`'in'` = capital ajouté au compte, `'out'` = capital retiré). Cette table est l'input du calcul Modified Dietz (cf. [ADR 0008](0008-modified-dietz-pour-rendement.md)) qui sépare les **apports** des **gains réels** pour calculer la performance d'un compte d'investissement.
|
||||
|
||||
La question structurante : que se passe-t-il si l'utilisateur supprime une transaction qui est liée à un transfert de bilan ?
|
||||
|
||||
Trois politiques de FK sont possibles côté SQL :
|
||||
|
||||
| Politique | Comportement | Intégrité historique | Friction utilisateur |
|
||||
|-----------|--------------|----------------------|----------------------|
|
||||
| `ON DELETE CASCADE` | Suppression de la transaction supprime aussi le transfert | ❌ Le rendement Modified Dietz d'une période passée change rétroactivement | ✅ Aucune friction : tout disparaît silencieusement |
|
||||
| `ON DELETE SET NULL` | Le transfert reste mais perd son `transaction_id` | ⚠ Le transfert devient "orphelin" : direction connue mais montant introuvable (les montants vivent dans `transactions.amount`) | ⚠ État partiellement valide |
|
||||
| **`ON DELETE RESTRICT`** | La suppression est bloquée par SQLite tant que des transferts pointent vers la transaction | ✅ Préservée : un rendement déjà calculé reste reproductible | ⚠ L'utilisateur doit délier explicitement avant suppression |
|
||||
|
||||
Contraintes du contexte :
|
||||
- Modified Dietz produit un rendement **R** sur une période **[t1, t2]** à partir de `(V_début, V_fin, [(date, montant)])`. Si une `transaction` liée disparaît silencieusement (CASCADE), la fonction reste pure mais ses inputs changent — `R` calculé hier ≠ `R` calculé aujourd'hui sur la même période. C'est exactement l'antithèse de la reproductibilité financière.
|
||||
- Le calcul est déclenché à la demande (chargement de `BalanceAccountsTable`), il n'y a pas de cache server-side. Donc l'historique de "ce que le user a vu hier" n'existe pas : si les inputs bougent, le résultat affiché change sans que l'utilisateur sache pourquoi.
|
||||
- L'usage attendu de la suppression de transactions est rare et lié à des erreurs d'import (doublons, mauvaise source). Bloquer ce cas avec un message clair est acceptable.
|
||||
|
||||
## Decision
|
||||
|
||||
**Adopter `ON DELETE RESTRICT` sur `balance_account_transfers.transaction_id`** :
|
||||
|
||||
```sql
|
||||
CREATE TABLE balance_account_transfers (
|
||||
...
|
||||
transaction_id INTEGER NOT NULL,
|
||||
...
|
||||
FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE RESTRICT
|
||||
);
|
||||
```
|
||||
|
||||
### UX correspondante
|
||||
|
||||
La couche service `transactionService.ts` détecte l'erreur SQLite `FOREIGN KEY constraint failed` et la transforme en `TransactionLinkedToBalanceError` typée, qui porte la liste des comptes liés. La UI affiche alors :
|
||||
|
||||
> **Cette transaction est liée au compte de bilan _<nom du compte>_.**
|
||||
> Pour la supprimer, déliez-la d'abord : ouvrez le compte → Lier transferts → décochez cette transaction.
|
||||
|
||||
Avec un lien direct vers la `LinkTransfersModal` du compte concerné. L'utilisateur ne peut pas se retrouver bloqué : le chemin de déliaison est toujours dispo, à un clic du message d'erreur.
|
||||
|
||||
Pour les chemins bulk (`deleteImportWithTransactions`, `deleteAllImportsWithTransactions`), une pré-vérification SELECT (`LIMIT 50`) liste les premiers transferts liés AVANT de tenter la suppression — l'utilisateur voit un message agrégé "X transactions de cet import sont liées à des comptes de bilan" plutôt qu'un raw FK error toast.
|
||||
|
||||
### Direction CASCADE conservée pour `account_id`
|
||||
|
||||
À noter : la même table a une autre FK, `account_id`, configurée en `ON DELETE CASCADE`. Si l'utilisateur supprime un compte de bilan, ses transferts disparaissent — c'est cohérent puisque les rendements de ce compte n'ont plus lieu d'être.
|
||||
|
||||
L'asymétrie est délibérée :
|
||||
- `account_id` ON DELETE CASCADE : le compte de bilan est l'objet "principal" du domaine Bilan, sa suppression nettoie ses dépendances internes
|
||||
- `transaction_id` ON DELETE RESTRICT : la transaction est externe au domaine Bilan, sa suppression ne doit pas casser silencieusement les calculs
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Reproductibilité Modified Dietz garantie.** Un rendement calculé sur une période passée ne peut pas changer à cause d'une suppression invisible côté `transactions`.
|
||||
- **Audit trail préservé.** L'utilisateur qui consulte un compte de bilan voit toujours les mêmes flux pour les mêmes périodes, peu importe quand il consulte.
|
||||
- **Erreur visible et actionnable.** L'utilisateur reçoit un message concret avec un chemin clair pour résoudre, plutôt qu'une suppression silencieuse qui invaliderait l'historique financier.
|
||||
- **Aligné avec la convention SQL existante du projet.** D'autres FK utilisent déjà `RESTRICT` quand l'intégrité est critique (cf. `balance_accounts.balance_category_id`, `balance_snapshot_lines.account_id`).
|
||||
|
||||
### Negative / trade-offs
|
||||
|
||||
- **Friction utilisateur** : forcer l'unlink explicite avant suppression ajoute 2 clics (ouvrir le compte → ouvrir LinkTransfersModal → décocher → revenir → supprimer). Acceptable car le cas est rare et le coût d'un rendement faux est élevé.
|
||||
- **Couplage UI ↔ erreur SQL** : `transactionService.ts` doit détecter le format d'erreur SQLite (`FOREIGN KEY constraint failed`) et le mapper sur `TransactionLinkedToBalanceError`. Si tauri-plugin-sql change le format du message d'erreur, le mapping casse silencieusement (mitigé par les tests d'intégration co-localisés dans `transactionService.test.ts`).
|
||||
- **Pré-vérification bulk a un coût** : un `SELECT ... LIMIT 50` sur `balance_account_transfers` à chaque suppression d'import. Négligeable en pratique (la table reste petite), mais à surveiller si un utilisateur a des dizaines de milliers de transferts.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **`ON DELETE CASCADE`.** Rejeté : trahit la promesse de reproductibilité du calcul Modified Dietz. Un rendement vu hier peut changer sans signal vers l'utilisateur.
|
||||
- **`ON DELETE SET NULL` + transferts orphelins.** Rejeté : laisse la base dans un état "valide mais incohérent". Le transfert sait sa direction mais a perdu son montant (qui vit dans `transactions.amount`). Le code Modified Dietz devrait alors filtrer les orphelins, et l'utilisateur ne saurait plus pourquoi son rendement a changé. Pire que CASCADE, qui au moins est explicite.
|
||||
- **Pas de FK du tout, juste un INTEGER orphelin possible.** Rejeté : retire toute garantie d'intégrité référentielle, et les calculs de rendement deviendraient une chasse aux pointeurs cassés.
|
||||
- **Soft-delete des transactions (`deleted_at` au lieu de DELETE)** pour préserver les données liées tout en cachant la transaction de l'UI. Rejeté pour l'instant : les transactions n'ont pas de soft-delete dans le schéma actuel et l'introduire ouvrirait un chantier transversal (toutes les requêtes de transactions devraient filtrer `WHERE deleted_at IS NULL`). À reconsidérer si plusieurs domaines en font la demande.
|
||||
|
||||
## References
|
||||
|
||||
- Implémentation : `src-tauri/src/database/balance_schema.sql` (FK definition), `src/services/transactionService.ts` (`TransactionLinkedToBalanceError` mapping)
|
||||
- Tests : `src/services/transactionService.test.ts` (mapping FK error → typed error), `src/__integration__/balance-flow.test.ts` (lien + tentative de suppression bloquée)
|
||||
- Spec : [`spec-decisions-bilan.md`](../../spec-decisions-bilan.md) — décision "FK `balance_account_transfers.transaction_id` : `ON DELETE RESTRICT` + UI force unlink avec message clair"
|
||||
- ADR liée : [0008 — Modified Dietz pour le calcul du rendement](0008-modified-dietz-pour-rendement.md)
|
||||
Loading…
Reference in a new issue