Simpl-Resultat/docs/adr/0010-fk-restrict-balance-transfers.md
le king fu 098e15bb5c 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>
2026-04-25 17:06:40 -04:00

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 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 :

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 — 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