- 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>
7.7 KiB
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) 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 unetransactionliée disparaît silencieusement (CASCADE), la fonction reste pure mais ses inputs changent —Rcalculé hier ≠Rcalculé 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 :
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_idON DELETE CASCADE : le compte de bilan est l'objet "principal" du domaine Bilan, sa suppression nettoie ses dépendances internestransaction_idON 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à
RESTRICTquand 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.tsdoit détecter le format d'erreur SQLite (FOREIGN KEY constraint failed) et le mapper surTransactionLinkedToBalanceError. 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 danstransactionService.test.ts). - Pré-vérification bulk a un coût : un
SELECT ... LIMIT 50surbalance_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 danstransactions.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_atau 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 filtrerWHERE 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(TransactionLinkedToBalanceErrormapping) - 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— décision "FKbalance_account_transfers.transaction_id:ON DELETE RESTRICT+ UI force unlink avec message clair" - ADR liée : 0008 — Modified Dietz pour le calcul du rendement