# 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)