diff --git a/docs/adr/0008-modified-dietz-pour-rendement.md b/docs/adr/0008-modified-dietz-pour-rendement.md new file mode 100644 index 0000000..a0a58aa --- /dev/null +++ b/docs/adr/0008-modified-dietz-pour-rendement.md @@ -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, + value_end: f64, + net_contributions: f64, + return_pct: Option, // None si dénominateur ≈ 0 + annualized_pct: Option, // (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. diff --git a/docs/adr/0009-proxy-price-fetching-via-maximus-api.md b/docs/adr/0009-proxy-price-fetching-via-maximus-api.md new file mode 100644 index 0000000..73f92b1 --- /dev/null +++ b/docs/adr/0009-proxy-price-fetching-via-maximus-api.md @@ -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 + │ 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 ` 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 ` + `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: , 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. diff --git a/docs/adr/0010-fk-restrict-balance-transfers.md b/docs/adr/0010-fk-restrict-balance-transfers.md new file mode 100644 index 0000000..06113a3 --- /dev/null +++ b/docs/adr/0010-fk-restrict-balance-transfers.md @@ -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 __.** +> 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)