Files
crowdlending-app/MEMORY.md
T
2026-07-14 15:40:57 +02:00

67 KiB
Raw Blame History

MEMORY.md — Crowdlending Tracker

Dernière mise à jour: 2026-07-14 (session 14)


Résumé du projet

Application web mono-utilisateur multi-investisseur de suivi de crowdlending (prêts participatifs). Saisie manuelle + import Excel. Usage personnel/familial, non déployé publiquement.

  • Backend : Node.js / Express + SQLite (better-sqlite3), port 4000
  • Frontend : React 18 + Vite, port 5173 (dev) / 8080 (Docker)
  • Auth : JWT 7j, header Authorization: Bearer
  • DB : backend/data/crowdlending.db (WAL mode)
  • Docker : docker compose up -d --build → frontend :8080, backend :4000

État des migrations DB (dernière colonne ajoutée)

Les migrations sont dans backend/src/db/index.js (pattern PRAGMA table_info() + ALTER TABLE ADD COLUMN). Ne jamais toucher schema.sql pour les évolutions.

Migration Colonne / opération
investisseurs prenom TEXT, type TEXT (famille/entreprise), is_principal INTEGER
remboursements cashback REAL, interets_nets REAL (suppression autres_taxes)
investissements rename date_debutdate_premiere_echeance, date_echeancedate_cible, freq_interets TEXT, date_debut_simul TEXT
users role TEXT ('user'/'admin')
plateformes domiciliation TEXT, fiscalite TEXT, taux_fiscalite_locale REAL
investissements categorie_id INTEGER (FK → categories_plateforme, ON DELETE SET NULL)
remboursements Recréation de table : investissement_id rendu nullable, ajout bonus_plateforme_id, bonus_investisseur_id, type TEXT DEFAULT 'normal'
plateformes methode_remboursement TEXT NOT NULL DEFAULT 'portefeuille'
remboursements methode_remboursement TEXT NOT NULL DEFAULT 'portefeuille'
depots_retraits remboursement_id INTEGER (lien vers le remboursement source pour les retraits auto)

Fonctionnalités clés actuelles

Toggle Brut/Net global

  • Géré via UiContext.displayMode ('net' | 'brut'), persisté localStorage cl_display_mode
  • Affiché dans la topbar (Layout.jsx) — widget .display-toggle
  • Appliqué sur : Dashboard, Investissements, InvestissementDetail, Remboursements
  • Règle : toute nouvelle page avec intérêts → const netMode = displayMode === 'net'
  • Le toggle interne de InteretsChart a été supprimé — reçoit netMode en prop

Modèle fiscal

interets_nets = interets_bruts - prelev_sociaux - prelev_forfaitaire
net_recu = capital + cashback + interets_nets
PFU France = 30% (17.2% PS + 12.8% IR)

Estimation nette projections : interets_bruts * (1 - (prelev_sociaux + impot_revenu) / 100)

Modèle plateforme (évolution récente)

  • domiciliation : france | zone_europeenne | hors_zone_europeenne
  • fiscalite : flat_tax | sans_fiscalite_locale | avec_fiscalite_locale
  • methode_remboursement : portefeuille | compte_courant | choix_investisseur
    • portefeuille (défaut) : remboursement sur le porte-monnaie de la plateforme (méthode fermée)
    • compte_courant : remboursement sur le compte courant de l'investisseur (méthode fermée)
    • choix_investisseur : l'investisseur choisit sur la plateforme (méthode ouverte)
  • Règle : si domiciliation === 'france'fiscalite forcé à flat_tax, taux_fiscalite_locale null
  • Helpers dans Settings.jsx : applyDomiciliationChange, applyFiscaliteChange, fmtFiscalite, METHODE_REMB_LABELS

Navigation

  • Sidebar : 5 pages data uniquement (Dashboard, Investissements, Dépôts/Retraits, Remboursements, Fiscalité)
  • Settings / MonCompte / Admin → UserMenu popup (coin bas gauche)
  • Pages "compte" : layout account-layout, navigation par ?section= URL param
  • Routes dépréciées redirigées : /preferences → /settings?section=apparence, /imports → /settings?section=imports

Investisseur scope

  • activeView : 'single' | 'all'
  • Vue "tous" → passer { scope: 'all' } à l'API
  • Backend filtre toujours par user_id via JWT

Patterns à respecter

API calls

api.get('/investissements', { scope: 'all' })
api.post('/remboursements', payload)
api.put(`/investissements/${id}`, payload)
api.del(`/plateformes/${id}`)

Formatage

import { fmtEUR, fmtPct, fmtDate, fmtStatut, memberLabel, today } from '../utils/format.js'

Erreurs form

const [err, setErr] = useState(null)
// {err && <div className="error">{err}</div>}

Modales

<Modal open={bool} title="…" onClose={fn} footer={<></>} width={680}>

Pas de couleurs hardcodées

Toujours var(--primary), var(--success), var(--danger), var(--text), var(--text-muted), var(--border), var(--surface-2), etc.


Types / énumérations de référence

Entité Valeurs
investissements.statut en_cours | rembourse | en_retard | procedure | cloture
investissements.type_remb in_fine | amortissable | differe
investissements.freq_interets mensuel | trimestriel | in_fine
remboursements.type normal | bonus_parrainage | bonus_plateforme
remboursements.statut paye | retard | partiel | impaye
depots_retraits.type depot | retrait
investisseurs.type famille | entreprise
users.role user | admin

Sections Settings

Section (?section=) Contenu
apparence (défaut) thème, police, langue, devise
plateformes CRUD plateformes + catégories
categories CRUD catégories de plateformes
garanties référentiel garanties
pfu taux PFU par année
notation critères notation par plateforme
imports import CSV/XLS

Endpoints API principaux

POST   /api/auth/register | login | me
GET/POST/PUT/DELETE  /api/investisseurs
GET/POST/PUT/DELETE  /api/plateformes
GET/POST/PUT/DELETE  /api/depots-retraits
GET/POST/PUT/DELETE  /api/investissements
GET/POST/PUT/DELETE  /api/remboursements
GET/POST/DELETE      /api/simul
POST                 /api/simul/generate
GET                  /api/dashboard
GET                  /api/fiscal-2778?annee=YYYY
GET                  /api/fiscal-2778/export?annee=YYYY
GET/POST             /api/pfu
GET/POST/PUT/DELETE  /api/notation
GET/POST/PUT/DELETE  /api/garanties
GET/POST/DELETE      /api/objectifs?type=&annee=
GET/POST/PUT/DELETE  /api/categories
POST                 /api/imports/preview | apply
GET                  /api/imports/history
GET/POST/PUT/DELETE  /api/admin/users (requireAdmin)

Headers requis (hors /auth/*) : Authorization: Bearer <jwt> + X-Investisseur-Id: <id> pour routes scopées.


Démarrage local

# Backend
cd backend && npm install && npm run dev   # :4000

# Frontend
cd frontend && npm install && npm run dev  # :5173 (proxy /api → 4000)

Variables d'env : copier .env.example.env, renseigner JWT_SECRET.


Fichiers clés à connaître

Fichier Rôle
backend/src/db/index.js Init DB + toutes les migrations
backend/src/db/schema.sql Schéma de référence (CREATE TABLE) — ne pas modifier pour les évolutions
frontend/src/api.js Client HTTP centralisé (gère JWT)
frontend/src/utils/format.js fmtEUR, fmtDate, fmtPct, fmtStatut, memberLabel, today
frontend/src/context/UiContext.jsx displayMode (brut/net), sidebar, fontScale, langue, devise
frontend/src/components/Layout.jsx Shell app, topbar, toggle Brut/Net
frontend/src/pages/Settings.jsx Hub paramètres, helpers fiscalité plateforme

Layout deux colonnes — pattern Settings (session 2026-05-08)

Les sections Plateformes et Catégories d'investissement de Settings.jsx utilisent désormais le même pattern deux colonnes que DepotsRetraits.jsx :

  • Classes CSS réutilisées : .dr-mouvements-layout, .dr-mouvements-list, .dr-mouvements-detail, .dr-row, .dr-row-selected, .dr-detail, .dr-detail-empty, .dr-detail-title, .dr-detail-fields, .dr-detail-field, .dr-detail-label, .dr-detail-value, .dr-detail-footer, .dr-detail-edit-btn
  • Plateformes : tableau réduit (Nom + Catégories d'invest. + Nb invest.), panneau détail droite avec PlatDetailPanel, création via modale showNewPlat, suppression dans la modale d'édition (bouton "Supprimer" dans le footer gauche de la modale — logique inline avec confirm() + early return si annulé)
  • Catégories : tableau réduit (Nom + Nb plateformes), panneau détail droite avec CatDetailPanel, création via modale showNewCat, suppression contextuelle dans le panneau (bouton rouge activé si non-utilisée, grisé si utilisée), export CSV/JSON
  • Auto-sélection du premier item : useEffect avec setSelectedX(prev => prev ? prev : items[0])
  • nb_investissements ajouté au backend dans GET /api/plateformes via sous-requête SQL (pas de nouvel endpoint)
  • Pas de PUT /api/categories → pas de renommage de catégorie possible côté UI

CategorySelect — dropdown position:fixed (session 2026-05-08)

CategorySelect.jsx a été réécrit pour corriger le clipping du dropdown dans les modales (overflow: auto clippe les descendants position: absolute).

Solution : dropdown en position: fixed avec position calculée via getBoundingClientRect() :

  • triggerRef sur le bouton déclencheur
  • useLayoutEffect mesure rect.bottom + 4 / rect.left / rect.width à chaque ouverture
  • Dropdown rendu en dehors du .cat-select-wrap (comme sibling dans le <>...</>) avec id="cat-select-dropdown-portal" et zIndex: 9999
  • Click-outside exclut à la fois wrapRef et le portal
  • scroll (capture phase) + resize ferment le dropdown

Nomenclature — Catégories (session 2026-05-08)

  • Labels utilisateur : "Catégories d'investissement" (section Settings, modales, boutons, export)
  • Identifiants code : inchangés (categories, categorie_id, categories_plateforme, /api/categories)
  • Dans les tableaux/badges : abréviation "Catégories d'invest." acceptable

Catégorie d'investissement sur les investissements (session 2026-05-08)

Modèle de données

  • Colonne categorie_id INTEGER ajoutée sur investissements (migration dans db/index.js), FK vers categories_plateforme(id) ON DELETE SET NULL
  • Backend retourne categorie_nom via LEFT JOIN categories_plateforme cp ON cp.id = i.categorie_id dans GET /investissements et GET /investissements/:id
  • Schema Zod : categorie_id: z.number().int().positive().nullable().optional()

Logique de sélection par défaut (à respecter partout)

// 0 catégories liées à la plateforme → CategorySelect libre (toutes cats, mode single-select)
// 1 catégorie → auto-sélectionnée, select disabled
// 2+ catégories → select activé, défaut = la plus utilisée dans rows (user-wide), sinon première
function defaultCategorieId(platId, plats, rows = []) {
  const plat = plats.find(p => p.id === Number(platId));
  if (!plat || !plat.categories || plat.categories.length === 0) return '';
  if (plat.categories.length === 1) return plat.categories[0].id;
  const counts = {};
  for (const r of rows) {
    if (r.categorie_id && plat.categories.some(c => c.id === r.categorie_id))
      counts[r.categorie_id] = (counts[r.categorie_id] || 0) + 1;
  }
  if (Object.keys(counts).length > 0)
    return Number(Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0]);
  return plat.categories[0].id;
}
  • Dans InvestissementDetail.jsx : version sans rows (pas de liste disponible), 2+ → première de la liste
  • InvestissementDetail.defaultCategorieId : signature (platId, plats) seulement

Ouverture de modale édition — règle critique

Toujours initialiser categorie_id avec row.categorie_id || defaultCategorieId(...) et NON row.categorie_id || ''. Sans ça, les anciens investissements (categorie_id null en base) affichent la bonne catégorie visuellement (select disabled sur 1 seul choix) mais envoient null au backend car l'état React reste à ''.

CategorySelect en mode single-select

Quand CategorySelect est utilisé pour un champ à valeur unique (ex : categorie_id) :

selected={form.categorie_id ? [Number(form.categorie_id)] : []}
onChange={ids => setForm(f => ({ ...f, categorie_id: ids[ids.length - 1] ?? '' }))}

ids[ids.length - 1] fonctionne car toggle() ajoute à la fin — unchecking donne [], switching donne [ancien, nouveau].

Fichiers modifiés

Fichier Changement
backend/src/db/index.js Migration categorie_id sur investissements
backend/src/routes/investissements.js Schema + GET/POST/PUT avec categorie_id/categorie_nom
frontend/src/pages/Investissements.jsx Import CategorySelect, état categories, defaultCategorieId, champ dynamique modale
frontend/src/pages/InvestissementDetail.jsx Idem + affichage dans "Informations du projet"

Bonus Parrainage / Bonus Plateforme (session 3 — 2026-05-08)

Modèle de données

  • remboursements.type : 'normal' | 'bonus_parrainage' | 'bonus_plateforme'
  • Pour type normal : investissement_id obligatoire, bonus_plateforme_id null
  • Pour les bonus : bonus_plateforme_id obligatoire, investissement_id null, bonus_investisseur_id pour le scope
  • Seul champ saisi pour les bonus : cashback (stocké aussi dans net_recu)

Frontend — sentinelles dans la modale Remboursements

const BONUS_VALUES = ['BONUS_PARRAINAGE', 'BONUS_PLATEFORME'];
const BONUS_TYPE_MAP = { BONUS_PARRAINAGE: 'bonus_parrainage', BONUS_PLATEFORME: 'bonus_plateforme' };
const isBonus = BONUS_VALUES.includes(form.investissement_id);
  • Options ajoutées au select investissement : value="BONUS_PARRAINAGE" et value="BONUS_PLATEFORME"
  • Quand isBonus : affiche select plateforme (requis) + select investisseur optionnel (vue 'all'), masque capital/intérêts/prélèvements
  • Payload envoyé : { type, bonus_plateforme_id, bonus_investisseur_id, date_remb, cashback, statut, notes }
  • openEdit : mappe r.type → sentinel pour pré-remplir le select

Backend — route remboursements

  • GET : LEFT JOIN investissements + COALESCE pour résoudre plateforme_id et investisseur_id
  • Alias critique : COALESCE(i.plateforme_id, r.bonus_plateforme_id) AS plateforme_id (pas resolved_plateforme_id — le frontend utilise r.plateforme_id partout pour l'agrégation et les filtres)
  • nom_projet : CASE r.type WHEN 'bonus_parrainage' THEN '— Bonus Parrainage' WHEN 'bonus_plateforme' THEN '— Bonus Plateforme'
  • capital_restant_du : correlated subquery (pas window function) pour gérer les NULL
  • Vue v_interets_annuels : filtre AND r.type = 'normal' pour exclure les bonus

Migration DB (db/index.js)

  • Recréation complète de la table remboursements via table temporaire __repair_remboursements
  • Guard : !rembColsBonus.includes('bonus_plateforme_id')
  • PRAGMA foreign_keys = OFF avant, ON après
  • Drop/recreate de la vue v_interets_annuels (qui référence investissement_id)

Problèmes de compatibilité Node.js rencontrés (session 3)

  • ||= (logical OR assignment) dans plateformes.js causait SyntaxError: Unexpected token ']' sur Node v20.17.0 → remplacé par if (!map[x]) map[x] = []
  • db/index.js tronqué : les outils Edit/Write et cat >> tronquent parfois les fichiers longs. Solution fiable : reconstruire avec python3 (lecture + écriture du fichier entier)
  • Read tool vs disque : le Read tool peut afficher du contenu en cache qui ne correspond pas au fichier réel — toujours vérifier avec bash tail ou wc -l
  • Node v22.22 : version actuelle après upgrade depuis v20.17 — résout les problèmes de syntaxe
  • Octets nuls en fin de fichier : les opérations cat >> successives peuvent laisser des \x00 à la fin d'un fichier → SyntaxError: Invalid or unexpected token à la ligne N+1. Fix : python3 -c "content=open(f,'rb').read(); open(f,'wb').write(content.rstrip(b'\\x00'))"

Corrections session 4 — Remboursements (2026-05-09)

Page Remboursements — onglet "Remboursements"

  • Colonne "Type" ajoutée au tableau : badge "Parrainage" (bleu, var(--primary)) pour bonus_parrainage, badge "Bonus" (vert, var(--success)) pour bonus_plateforme. Colonnes Capital/Intérêts/Prélèvements affichent pour les bonus.
  • Filtre "Type" ajouté dans la barre de filtres : Tous / Remboursements / Bonus Parrainage / Bonus Plateforme. Désactive le filtre "Projet" quand un type bonus est sélectionné.
  • Logique filtre tableRows : quand un filtre projet est actif, les lignes bonus sont exclues (comportement explicite). Quand un filtre type bonus est actif, le filtre projet est ignoré.
  • Modal "Nouveau remboursement" : suppression du blocage investissementsActifs.length > 0 — la modale s'ouvre toujours, même si tous les investissements sont remboursés (utile pour saisir un bonus cashback).

Bug investisseur — nom dupliqué dans les modales

  • Cause : inv.nom contient déjà le nom complet (ex. "Olivier CROGUENNEC"), et inv.prenom vaut "Olivier". La concaténation ${inv.prenom} ${inv.nom} donnait "Olivier Olivier CROGUENNEC".
  • Fix : utiliser memberLabel(inv) (import depuis ../utils/format.js) à la place de la concaténation manuelle. memberLabel retourne simplement m.nom.
  • Règle à retenir : toujours utiliser memberLabel(inv) pour afficher le nom d'un investisseur dans les options/selects — jamais inv.prenom + inv.nom.

Session 6 — UX et calculs (2026-05-09)

Barre de recherche rapide de projets (Layout.jsx)

  • Composant ProjectSearch ajouté dans la topbar globale (Layout.jsx)
  • Ctrl+K / Cmd+K → focus sur la barre
  • Résultats filtrés (max 8) sur nom_projet et plateforme_nom, navigation ↑↓ Enter Escape
  • Clic ou Enter → navigate vers /investissements/${inv.id}
  • Classes CSS : .project-search-wrap (border primary, border-radius 20px, min-width 330px), .project-search-input (outline:none, box-shadow:none), .project-search-dropdown, .project-search-item, .project-search-item-name, .project-search-item-meta, .project-search-kbd, .project-search-clear
  • STATUT_LABELS dans ProjectSearch : { en_cours:'en cours', rembourse:'remboursé', en_retard:'en retard', procedure:'procédure', cloture:'clôturé' } — obligatoire pour avoir les accents corrects

Création investissement → redirection automatique vers le détail

  • Dans Investissements.jsx, après api.post('/investissements', payload) : navigate(/investissements/${created.id}) (au lieu d'un reload)

KPI dynamiques (année/plateforme) — pattern unifié

  • Architecture : allRowskpiRows (filtre plateforme + platYear/rembPlatYear/drPlatYear, sans filtre statut/type) → chartRows (+ statut/type) → rows (+ mois)
  • totals calculés depuis kpiRows pour que les KPI restent dynamiques
  • platYear / rembPlatYear / drPlatYear : états indépendants de filter.year — pour le sélecteur d'année du tableau par plateforme uniquement
  • DepotsRetraits : kpiSoldePortefeuille calculé live depuis backend (sans filtre année) OU cumulatif depuis allRows + allRemb jusqu'à ${drPlatYear}-12-31 (avec filtre année, inclut les remboursements methode=portefeuille)
  • KPI sur une seule ligne .dr-kpi-row en flexbox (.dr-kpi-row > .kpi { flex: 1 }) — s'adapte à n'importe quel nombre de KPI

Bouton "+" dans InvestissementDetail — header "Remboursements enregistrés"

  • Bouton .btn-icon à droite du titre (flex justify-content: space-between)
  • Appelle openNewRemb() : pré-remplit date du jour + methode_remboursement de la plateforme, ouvre la modale de saisie
  • CSS .btn-icon : 32×32px, border-radius 50%, color var(--text), hover → background var(--surface-2) + color var(--primary). .btn-icon svg { width:18px; height:18px } — toujours dimensionner via CSS, pas via attributs SVG width/height

Taux PFU — fallback dernière année connue

  • Avant : si année absente de la table PFU → 0% (bug projections futures)
  • Après : utiliser getLastKnownRates() = pfuRates.reduce((best, r) => r.annee > best.annee ? r : best, pfuRates[0])
  • Corrigé dans : Dashboard.jsx (getPfuReduction), InvestissementDetail.jsx (getRatesForYear), Remboursements.jsx (getRatesForYear + getPfuReduction)

Première période partielle (mois incomplet) — adjustFirstPartialPeriod

  • Ajouté dans backend/src/utils/schedule.js
  • Appelé à la fin de adjustSimulForActuals (après le recalcul capital ET après generateSimul pour le cas total_capital <= 0)
  • Logique : trouve le 1er remboursement réel avec interets_bruts > 0 → cherche l'entrée simul du même mois YYYY-MM → si interets_prevus (simul) > interets_bruts (réel) d'au moins 0,001 € → met à jour la première ligne avec le montant réel + reporte la différence sur la dernière échéance
  • Idempotent : si les valeurs sont déjà alignées, aucune modification
  • Ne s'applique pas aux prêts differe (versement unique, pas de période partielle)

Pièges / points d'attention

  1. net_recu_total dans Investissements = SUM(r.net_recu) = capital + cashback + intérêts nets — c'est une approximation du rendement net, pas uniquement les intérêts.
  2. XIRR : calculé uniquement sur statut === 'rembourse'. Flux bruts = capital + cashback + interets_bruts. Flux nets = net_recu.
  3. InteretsChart n'a plus de toggle interne — toujours passer netMode en prop depuis le parent.
  4. Migrations DB : guard PRAGMA table_info() obligatoire pour éviter les erreurs si colonne déjà présente.
  5. Admin : middleware requireAdmin sur /api/admin. Lien visible dans UserMenu uniquement si isAdmin.
  6. Job auto-statut : backend/src/jobs/autoStatut.js — met à jour les statuts investissements automatiquement.
  7. Modal + dropdown : ne jamais utiliser position: absolute pour un dropdown dans une modale — utiliser position: fixed + getBoundingClientRect() (cf. CategorySelect.jsx).
  8. Suppression dans modale : ne pas chaîner .then(() => closeModal()) sur une fonction qui appelle confirm() — inliner la logique avec early return si !confirm(...) pour éviter la fermeture sur "Annuler".
  9. Affichage investisseur : toujours memberLabel(inv) — jamais inv.prenom + ' ' + inv.nom car nom contient déjà le nom complet.

Session 7 — Auth, sécurité, section Admin Général (2026-06-15)

Système d'audit logs

  • Table audit_logs (id, actor_id, target_user_id, action, category, details JSON, ip_address, user_agent, created_at)
  • Util backend/src/utils/audit.js : audit(req, { action, category, actorId, targetUserId, details }) — silencieux, ne throw jamais
  • Route GET /api/admin/audit-logs (+ /categories) avec filtres page/limit/category/search/dateFrom/dateTo/userId, purge auto 30 jours
  • Instrumenté dans : auth.js (register, login_failed, login_success, 2fa), admin.js (user_created, status_changed, role_changed, user_deleted), invitations.js (invitation_sent, invitation_accepted)
  • Frontend : AuditLogsSection.jsx — badges catégories cliquables, recherche 350ms debounce, plage dates, pagination 50/page, DetailTooltip JSON au survol

Bug focus-loss sur InvitationRegister

  • Cause : composant Wrap défini à l'intérieur du corps de InvitationRegister() → new component type à chaque render → React démonte/remonte tous les enfants → perte de focus à chaque frappe
  • Fix : déplacer Wrap en dehors de la fonction, passer appInfo en prop
  • Règle : ne jamais définir un composant React à l'intérieur d'un autre composant

Indicateur de complexité mot de passe (PasswordStrength.jsx)

  • Composant frontend/src/components/PasswordStrength.jsx
  • Props : password: string, minLength: number = 8
  • 5 règles : len (≥ minLength), upper, lower, digit, special
  • 6 niveaux visuels : Très faible → Très fort (couleurs fixes)
  • Règle len dynamique via buildRules(minLength) appelé dans le composant
  • Déployé sur 5 écrans : Register, ResetPassword, InvitationRegister, MonCompte (SecurityForm), UsersSection (CreateUserModal)

Section Admin "Général" — paramètres globaux

  • Route backend GET/PATCH /api/admin/generalbackend/src/routes/general.js
  • Colonnes DB ajoutées sur smtp_config : allow_registration INTEGER DEFAULT 1, min_password_length INTEGER DEFAULT 8
  • Route publique /api/app-info enrichie : expose allowRegistration et minPasswordLength (sans auth)
  • SmtpSection.jsx : champs appName/appUrl retirés (déplacés dans Général)
  • GeneralSection.jsx : 3 blocs — Identité (appName, appUrl), Accès (toggle auto-inscription), Sécurité (longueur min MDP)
  • Admin.jsx : section "Général" ajoutée en tête du groupe "Administration de la plateforme"

Contrôle auto-inscription

  • App.jsx : route /register<Navigate to="/login" replace /> si allowRegistration=false
  • Login.jsx : lien "Créer un compte" conditionnel sur appInfo.allowRegistration !== false
  • Fetch /api/app-info au montage dans App.jsx, état initial allowRegistration: true (jamais bloquant par défaut)

minPasswordLength — propagation

  • Chaque écran password fetch /api/app-info au montage et extrait minPasswordLength
  • useState(8) comme valeur initiale sur tous les écrans → jamais bloquant si l'API est lente
  • Register/ResetPassword/InvitationRegister : via appInfo.minPasswordLength || 8 (appInfo déjà fetché)
  • MonCompte (SecurityForm) et UsersSection (CreateUserModal) : state local minPasswordLength + useEffect fetch dédié

Session 8 — UX empty state + visibilité mot de passe (2026-07-03)

EmptyState → ouverture directe de l'ajout de plateforme

  • EmptyState.jsx : prop to par défaut passée de /settings?section=plateformes à /settings?section=plateformes&openAdd=1
  • PlateformesSection.jsx : useSearchParams + useEffect détecte openAdd=1 au montage → ouvre directement showAddPicker (modale "Ajouter une plateforme") → nettoie le paramètre de l'URL (replace: true) pour éviter la réouverture au refresh
  • Évite l'étape intermédiaire où l'utilisateur devait cliquer une seconde fois sur "+ Ajouter" après avoir été redirigé depuis un état vide (Dashboard, Investissements, Remboursements, DepotsRetraits)
  • Pattern réutilisable : pour toute redirection "action directe" similaire depuis un état vide, ajouter un query param dédié + useEffect de consommation/nettoyage dans la section cible

Composant PasswordInput — afficher/masquer mot de passe

  • Nouveau composant frontend/src/components/PasswordInput.jsx : wrapper autour d'un <input type="password"> avec bouton œil (SVG inline, pas de lib externe) togglant type entre password/text
  • API : toutes les props (className, value, onChange, required, autoComplete, minLength, style, etc.) sont transmises telles quelles à l'<input> interne ; wrapperStyle optionnel pour le <div style="position:relative"> englobant
  • Déployé sur les 8 écrans contenant un champ mot de passe : Login, Register, ResetPassword (×2 champs), InvitationRegister (×2 champs), MonCompte (SecurityForm ×3 champs + changement email ×1 + désactivation 2FA ×1), admin/CreateUserSection, admin/UsersSection
  • Règle à respecter : tout nouveau champ mot de passe doit utiliser <PasswordInput> plutôt que <input type="password"> brut, pour garder l'UX cohérente sur toute l'app

Suppression définitive de compte (self-service)

  • Route DELETE /api/auth/me (requireAuth, body { password }) dans auth.js : vérifie le mot de passe (bcrypt), bloque si l'utilisateur est le dernier admin (COUNT(*) WHERE role='admin' <= 1), logge un audit account_self_deleted (catégorie account, details.initiated_by:'self') avant la suppression, notifie tous les autres admins (type security, lien /admin?section=audit-logs), puis DELETE FROM users WHERE id=?
  • Le nettoyage des données ne fait AUCUN delete manuel par table — il repose entièrement sur les FK ON DELETE CASCADE déjà en place sur user_id/investisseur_id/etc. (db.pragma('foreign_keys = ON') activé globalement dans db/index.js). C'est le même mécanisme que DELETE /api/admin/users/:id (admin.js) qui fait déjà un simple DELETE FROM users sans étape de nettoyage manuel
  • audit_logs.actor_id/target_user_id sont en ON DELETE SET NULL (pas CASCADE) : le log survit à la suppression de l'utilisateur, les infos identifiantes (email, display_name, role) sont dupliquées dans details JSON pour rester lisibles même une fois les FK à NULL
  • Frontend : AuthContext.deleteAccount(password)api.del('/auth/me', {password}) puis logout() ; api.del accepte maintenant un body optionnel (api.js)
  • DeleteAccountSection dans MonCompte.jsx, en bas de l'onglet Mon compte (après le bloc Préférences, pas dans Sécurité) : carte bordée rouge, warning, reveal formulaire mot de passe au clic, boutons alignés à droite (cohérent avec le reste de la page)
  • Après suppression : redirection vers /login?deleted=1, Login.jsx affiche une bannière verte de confirmation si ce paramètre est présent

Bug — profil principal / compte courant non créés hors /auth/register

  • Constat : seul /api/auth/register (auto-inscription) créait le profil investisseur principal (is_principal=1) ET le compte courant associé. Les deux autres parcours de création de compte en étaient dépourvus :
    • POST /api/admin/users (admin.js, CreateUserSection.jsx) : créait l'investisseur mais sans is_principal=1 et sans compte courant
    • POST /api/invitations/:token/register (invitations.js, InvitationRegister.jsx) : ne créait aucun investisseur ni compte courant
  • Cas réel trouvé en base : marine@croguennec.net (user #2, créée par invitation le 2026-06-18) n'avait aucun investisseur ; newargus@gmail.com (user #3, créé par l'admin le 2026-07-03) avait un investisseur mais is_principal=0 et zéro compte
  • Fix appliqué :
    1. admin.js (POST /users) et invitations.js (POST /:token/register) répliquent maintenant exactement la logique de auth.js : INSERT INTO investisseurs (..., is_principal) VALUES (..., 1) + INSERT INTO comptes (user_id, nom, type, investisseur_id) avec nom = 'Compte courant — ' + fullName
    2. Backfill idempotent ajouté en fin de backend/src/db/index.js (avant export default db) qui tourne à chaque démarrage : (1) crée un investisseur principal pour tout user qui n'en a aucun, (2) marque principal le plus ancien investisseur famille pour tout user qui n'a pas de principal, (3) crée le compte courant manquant pour tout investisseur principal qui n'en a pas. Toutes les requêtes utilisent NOT EXISTS → sans effet une fois les données corrigées.
  • Règle à retenir : toute nouvelle voie de création de compte utilisateur doit répliquer les 2 inserts de auth.js (investisseurs avec is_principal=1 + comptes type compte_courant) — ne pas dupliquer seulement l'insert investisseurs.

Session 9 — Import de données & cohérence mono/multi-détenteur (2026-07-04)

Ajout de plateforme depuis le référentiel — un seul champ demandé

  • PlateformesSection.jsx (PlatPickerModal) : au clic sur une plateforme du référentiel, une vue de confirmation demande uniquement la date d'ouverture du compte (« Pouvez-vous SVP préciser la date d'ouverture de votre compte sur cette plateforme ? »), pré-remplie à la date du jour (localToday() — jamais .toISOString(), cf. piège timezone ci-dessous). Tout le reste (domiciliation, fiscalité…) vient du référentiel.

Colonne "Détenteur" — masquée si mono-détenteur

  • Pattern à répliquer partout : const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1; (ou investisseurs.length > 1 si pas de liste de plateformes disponible)
  • Déployé sur : Investissements.jsx, Remboursements.jsx, Plateformes.jsx, PlateformesSection.jsx, ComptesSection.jsx, DepotsRetraits.jsx — colonnes <th>/<td> conditionnées + colSpan ajusté + tfoot spacer
  • Idem sur les exports (XLS/CSV/JSON) : passer multiDetenteur en paramètre à la fonction d'export (ex. mouvToXLS(rows, multiDetenteur)) et spreader conditionnellement ...(multiDetenteur ? { 'Détenteur': ... } : {})

DepotsRetraits — bug empty-state après premier dépôt (plateforme flat_tax)

  • Cause : pour une plateforme flat_tax, submit() ouvre corrModal (vérification du solde déclaré) au lieu d'appeler load() directement ; l'early-return de l'empty-state (!loading && !modalOpen && allRows.length === 0) masquait la <CorrectionModal> rendue plus bas dans le JSX.
  • Fix : ajouter !corrModal.open à la condition de l'early-return.

Import — résolution de noms (plateforme/investissement) au lieu d'ID stricts

  • Backend imports.js : resolveRefId(value, idSet, nameMap, label) accepte un ID numérique OU un nom texte (normalisé accents/casse via normalizeName) pour plateforme_id (modules depots_retraits/investissements) et investissement_id (module remboursements)
  • Frontend : auto-mapping des colonnes par synonymes (FIELD_SYNONYMS + normalizeHeader()) en plus du match exact ; auto-remplissage du champ "Valeur par défaut" quand toutes les lignes d'échantillon résolvent vers la même plateforme/investissement (resolvePreviewMatch)

Import — protection anti-doublon

  • Clé de détection par module (avant insertion, dans la transaction) :
    • depots_retraits : investisseur + plateforme + date + type + montant (tolérance 0,005)
    • investissements : investisseur + plateforme + nom_projet + date_souscription
    • remboursements : investissement + date_remb + capital + interets_bruts (tolérance 0,005)
    • plateformes : déjà couvert par UNIQUE(nom) → reclassé en doublon plutôt qu'erreur
  • Compteur duplicates distinct de skipped (vraies erreurs), colonne DB imports.rows_duplicates, affiché dans l'historique

Import — anomalie date antérieure à la date d'ouverture de la plateforme

  • Pour depots_retraits/investissements uniquement (pas remboursements, lien plateforme trop indirect) : la date la plus ancienne rencontrée par plateforme dans le fichier importé (doublon ou non) est comparée à plateformes.date_ouverture
  • Si anomalie : réponse /apply inclut anomalies: [{ plateforme_id, plateforme_nom, date_ouverture_actuelle, date_detectee }]
  • Nouvelle route PATCH /api/plateformes/:id/date-ouverture (payload minimal { date_ouverture }, ne PAS repasser par le PUT /:id complet)
  • ImportsSection.jsx affiche une bannière par anomalie avec bouton "Corriger la date d'ouverture (JJ/MM/AAAA)"

Import généré par IA — nouveau bloc dans ImportsSection.jsx

  • Bloc "Import généré par IA" : prompt dynamique (DEFAULT_IA_IMPORT_PROMPT, template {{MODULE_LABEL}}/{{FIELDS_LIST}}/{{REFERENCE_SECTION}}, éditable et persisté localStorage) listant les champs attendus du module cible + les plateformes/investissements existants ; l'utilisateur colle le JSON généré par l'IA (avec ou sans fence json) qui est réinjecté dans le pipeline preview/apply existant via un Blob/File virtuel (pas de code dupliqué)
  • Règle imposée au prompt : si le fichier ne permet pas d'identifier avec certitude la plateforme (ou l'investissement pour les remboursements), l'IA doit poser la question à l'utilisateur en ne proposant QUE les noms existants comme réponses possibles, et attendre la réponse avant de générer le JSON — plutôt que de deviner ou d'omettre le champ silencieusement
  • Page restructurée en 3 blocs séquentiels : "1. Contexte de l'import" (sélecteur de module) → "2. Fichier source" → "3. Mappage des colonnes" ; le bloc "Dossier investissement" n'apparaît que si le module actif est investissements

Bug — suppression de compte cassée par plateforme_id ON DELETE RESTRICT

  • Symptôme : DELETE /api/auth/me échouait avec SqliteError: FOREIGN KEY constraint failed (code SQLITE_CONSTRAINT_TRIGGER) à auth.js:319.
  • Cause : depots_retraits.plateforme_id et investissements.plateforme_id étaient en ON DELETE RESTRICT (seuls FK RESTRICT du schéma) — lors du DELETE FROM users, les branches de cascade users→plateformes et users→investisseurs→depots_retraits/investissements sont indépendantes ; SQLite peut supprimer la plateforme avant les lignes qui la référencent encore, déclenchant RESTRICT.
  • Fix : migration fixPlateformeCascade dans db/index.js (recréation table via __repair_*, RESTRICT→CASCADE) + schema.sql mis à jour + protection déplacée côté application dans DELETE /api/plateformes/:id (vérification explicite du nombre d'investissements/dépôts-retraits avant suppression, message clair).
  • Règle à retenir : toute nouvelle table référençant plateformes/investisseurs/users doit être en CASCADE (ou SET NULL), jamais RESTRICT — sinon la suppression de compte se recasse. Les protections anti-suppression-accidentelle doivent être implémentées côté route, pas côté contrainte FK.

Piège outillage — mount bash périmé après édition Edit/Write

  • Après une édition via l'outil Edit/Write (côté fichier réel), une vérification immédiate via mcp__workspace__bash (wc/tail/node --check) peut montrer une version périmée/tronquée du fichier alors que le fichier réel est complet et correct — le mount bash ne se resynchronise pas instantanément après une écriture Windows-side.
  • Règle : vérifier l'intégrité d'un fichier édité via l'outil Read (relire la queue, vérifier la fermeture propre), pas via bash. N'utiliser bash pour vérifier que si l'écriture a elle-même été faite depuis bash (ex. reconstruction python3 après troncature confirmée par Read).

Session 10 — Bug racine dates prêts différés + audit trail + statuts auto (2026-07-12)

🔴 Bug racine trouvé et corrigé — migration db/index.js recalculait date_cible à CHAQUE démarrage

  • Symptôme initial : des dizaines de prêts differe avec date_cible aberrante (années 2100 à 2650), sans aucune trace dans investissement_historique, y compris des cas de "ping-pong" de statut (en_coursen_retard) sur les mêmes prêts toutes les quelques minutes.
  • Cause réelle (migration "renommage date_debutdate_premiere_echeance, date_echeancedate_cible", backend/src/db/index.js ~ligne 229) : le bloc de "correction de formule historique" (SET date_cible = date(date_premiere_echeance, '+(duree_mois-1)' months)) n'avait aucune garde (WHERE date_cible IS NULL) — il tournait donc à chaque redémarrage serveur, pour tous les investissements. Combiné à une 2e migration juste après (date_premiere_echeance = date_cible pour les prêts différés, censée maintenir l'égalité des deux dates), cela formait une boucle infinie : à chaque redémarrage, date_cible dérivait de +(duree_mois-1) mois supplémentaires — jamais tracé, car du code de migration, pas une action utilisateur ni une route API.
  • Pourquoi ça n'a été détecté que maintenant : le serveur redémarre très souvent en dev (node --watch), donc chaque édition de code déclenchait un cycle de dérive supplémentaire sur les prêts différés déjà touchés.
  • Fix : le bloc de correction ne s'exécute désormais que si migrationEnCours est vrai (colonnes date_debut/date_echeance encore présentes, càd le jour réel du renommage) — plus jamais au démarrage normal. La migration date_premiere_echeance = date_cible (prêts différés) enregistre maintenant un historique précis (correction_auto_echeancier) quand elle modifie quelque chose.
  • Point d'attention pour le futur : toute migration de données (pas juste ALTER TABLE ADD COLUMN) dans db/index.js doit être strictement idempotente/one-shot — soit via clause WHERE champ IS NULL, soit gardée derrière la détection de l'événement historique qui la justifie. Ne jamais laisser un UPDATE sans garde tourner à chaque boot.

Autres endroits déjà audités/corrigés pour la même classe de bug (dates modifiées sans trace)

  • POST /api/imports/dossier (imports.js) : la branche "SCÉNARIO UPDATE" (upsert par nom_projet+date_souscription) écrasait date_cible/date_premiere_echeance/montant_investi/taux_interet/duree_mois/type_remb/statut avec seulement un historique générique ("Mise à jour dossier"), sans le détail des champs modifiés. Fix : réutilise detectChangements/recordHistory/detectTypeEvenement (désormais exportés depuis investissements.js) pour logger un diff précis champ par champ, comme l'édition manuelle.
  • backend/fix_dates_cible.mjs : script autonome (node fix_dates_cible.mjs), jamais appelé automatiquement, corrige les date_cible > 2100-01-01 — toujours présent mais pas la source du bug de cette session.

Nouveautés Nettoyage de données (Settings.jsx → section imports, composant DataCleanupSection.jsx)

  1. "Corriger les dates des prêts différés" (POST /investissements/fix-differe-dates) :
    • Seuil d'écart désormais configurable via un select dans la modale : 3 / 6 / 12 / 18 / 24 mois (défaut 24, avant en dur "2 ans"). Body { seuilMois }, validé côté backend contre [3,6,12,18,24].
    • Régénère maintenant simul_remboursements via generateSimul() après correction (avant : échéancier laissé désynchronisé).
    • Déclenche immédiatement checkStatutsRetard() après correction (sinon un prêt dont la date recalculée tombe dans le passé restait affiché "en_cours" jusqu'au prochain minuit).
  2. "Vérifier la cohérence de l'échéancier des prêts différés" (nouveau, POST /investissements/check-echeancier-differe) — inséré juste après le précédent. Vérifie que simul_remboursements contient exactement 1 échéance à la date date_premiere_echeance/date_cible du prêt ; régénère sinon (generateSimulWithReinvestissements si réinvestissements présents, sinon generateSimul), log correction_auto_echeancier.

Job autoStatut.js — statuts automatiques enrichis

  • checkStatutsRetard() fait maintenant les deux sens : en_cours→en_retard (comme avant, log passage_auto_retard) ET en_retard→en_cours (nouveau, log retour_auto_en_cours) — mais uniquement si (a) date_cible est entre aujourd'hui et +30 ans (garde-fou anti date-encore-aberrante-mais-"future") ET (b) le dernier événement d'historique touchant le statut était bien passage_auto_retard (jamais d'annulation automatique d'un passage en retard décidé manuellement).
  • Chaque transition génère une notification utilisateur (table notifications) : type warning pour le passage en retard, type success pour le retour en cours, avec lien direct /investissements/:id.
  • TRACKED_FIELDS, recordHistory, detectChangements, detectTypeEvenement sont maintenant export depuis investissements.js (réutilisés par imports.js).

Piège outillage — lecture de la DB SQLite en direct depuis le sandbox bash (WAL + serveur actif)

  • Le fichier réel backend/data/crowdlending.db est en mode WAL et activement écrit par le serveur Node de l'utilisateur pendant la session. Une copie manuelle (cp séparé du .db/.db-wal/.db-shm) pendant que le serveur écrit produit des lectures tronquées ("database disk image is malformed") ou incohérentes entre deux requêtes successives — ce n'est PAS la preuve que les données changent réellement à chaque lecture.
  • better-sqlite3 du repo est un binaire natif Windows (node_modules/better-sqlite3/build/Release/better_sqlite3.node) → invalid ELF header dans le sandbox Linux. Utiliser python3 + module sqlite3 standard à la place, sur une copie locale dans /tmp.
  • Pour une lecture fiable d'un état ponctuel : privilégier les preuves de haut niveau déjà présentes dans l'app (page Historique du prêt, page Notifications avec horodatage) plutôt que des requêtes SQL répétées sur une DB en cours d'écriture concurrente.

Session 11 — Révision des conditions de prêt (2026-07-13)

Nouvelle fonctionnalité : révision des conditions (taux / date cible)

Distincte de investissement_historique (audit générique auto-détecté sur tout changement de champ) : trace un événement métier explicite (retard projet, renégociation…) avec motif obligatoire, dans sa propre table.

  • Table investissement_revisions (migration dans db/index.js) : date_effet, ancien_taux/nouveau_taux, ancienne_date_cible/nouvelle_date_cible, ancien_duree_mois/nouveau_duree_mois, motif TEXT NOT NULL.
  • Routes dans investissements.js (pas de nouveau fichier) : POST /:id/revisions, DELETE /:id/revisions/:rid (seule la dernière révision est supprimable, rollback vers l'état précédent + régénération de l'échéancier). GET /:id retourne revisions au même niveau que historique/simul.
  • Point technique clé : generateSimul() se base sur duree_mois, pas sur date_cible (champ d'affichage/contractuel). Donc si nouvelle_date_cible est fournie, duree_mois est recalculé (monthsDiff(date_premiere_echeance, nouvelle_date_cible) + 1) sinon la nouvelle date cible ne serait que cosmétique. Ancien/nouveau duree_mois tracés en base pour un rollback fidèle.
  • Helper regenererEcheancier(investissementId) dans investissements.js : appelle generateSimulWithReinvestissements si l'investissement a des réinvestissements, sinon generateSimul.
  • Frontend InvestissementDetail.jsx : modal "Réviser les conditions" avec 2 checkboxes explicites ("Modifier le taux" / "Modifier la date cible") — pas de convention implicite "champ vide = inchangé" (ambiguë, corrigée suite à retour utilisateur). Carte "Révisions du prêt" placée juste après "Informations du projet", avant "Remboursements enregistrés" (positionnement demandé explicitement par l'utilisateur). Badges "Révisé le" avec ancien barré → nouveau sur les champs Taux annuel / Date cible contractuelle. Avertissement non bloquant (texte violet #a78bfa) dans le formulaire "Modifier" standard si le champ a déjà été révisé — n'empêche pas l'édition directe (choix utilisateur : le formulaire standard reste une simple correction de fiche, pas un événement de révision).

🔴 Bug trouvé et corrigé — generateSimulWithReinvestissements ne préservait pas les échéances déjà payées

  • Symptôme : après une révision sur un prêt avec réinvestissement actif, la table "Projections de remboursements" perdait plusieurs mois d'échéances déjà payées (renumérotées à partir de 1 depuis la date d'effet de la révision), alors que les remboursements réels restaient intacts.
  • Cause : generateSimul() a une logique de "mode restructuration" (si date_debut_simul posé : conserve les échéances antérieures qui correspondent à un remboursement réel, supprime le reste, renumérote à partir des mois réellement écoulés) — mais generateSimulWithReinvestissements() ne l'avait jamais eue : elle supprimait tout l'échéancier et le régénérait en repartant à numero_echeance = 1. Ce chemin n'avait jamais été exercé avant (aucune fonctionnalité ne posait date_debut_simul sur un prêt ayant des réinvestissements).
  • Fix : dupliqué exactement la logique restructuration de generateSimul() dans generateSimulWithReinvestissements() (backend/src/utils/schedule.js).
  • Remède pour les investissements déjà impactés : menu ⋮ de "Projections de remboursements" → "Régénérer l'échéancier" (route /simul/recalculateadjustSimulForActualsgenerateSimulWithReinvestissements, maintenant corrigée).

🔴 Bug trouvé et corrigé — taux_interet = 0 traité comme "absent"

  • Cas réel : plateforme de cloud mining à l'arrêt → révision du taux à 0 %. Bloqué côté validation (z.number().positive() rejette 0) ET aurait été silencieusement ignoré par les gardes !taux_interet (falsy pour 0, null et undefined en JS) qui auraient empêché toute régénération d'échéancier même une fois la validation corrigée.
  • Fix : RevisionSchema.nouveau_taux.nonnegative(). Gardes !inv.taux_interetinv.taux_interet == null dans schedule.js (generateSimul, generateSimulWithReinvestissements, adjustSimulForActuals), simul.js (POST /generate), et 3 endroits d'affichage frontend (InvestissementDetail.jsx bouton "Régénérer l'échéancier", SimulRemboursements.jsx, Remboursements.jsx — message "taux manquant").
  • Règle à retenir : ne jamais tester un champ numérique métier potentiellement à 0 avec !champ — toujours champ == null (ou ?? pour les fallbacks, déjà en usage ailleurs dans le code pour taux_interet).

Tooltip "taux implicite" — détection de revalorisation non annoncée

  • Colonne "Intérêts" du tableau "Remboursements enregistrés" (InvestissementDetail.jsx) : tooltip (cell-tooltip, même pattern que la colonne Imposition) calculant le taux annuel implicite brut/net à partir de l'intérêt réellement versé à cette échéance et du capital restant dû juste avant (montant investi + réinvestissements antérieurs capital déjà remboursé aux échéances précédentes).
  • But : aider à repérer visuellement une revalorisation de taux non annoncée par la plateforme (le taux implicite change d'une ligne à l'autre sans qu'aucune révision n'ait été saisie).
  • Non calculé pour les prêts differe (un seul versement, pas de série à comparer) — guardé sur freq_interets === 'mensuel' || 'trimestriel'.

Piège outillage — confirmé à nouveau (cf. session 9)

Le mount bash de crowdlending-app était figé sur un instantané ancien (dates de fichiers plusieurs semaines avant la session), sans lien avec les éditions faites via l'outil Edit/Write dans cette session — wc -l/node --check sur le mount bash donnaient un fichier tronqué non représentatif. Solution utilisée : reconstruire le fichier édité dans le dossier outputs (accessible en écriture réelle depuis bash) via lecture complète par l'outil Read, puis node --check / esbuild --loader=jsx dessus pour une vérification syntaxique fiable — en complément (pas remplacement) de la relecture visuelle des zones éditées via Read.


Session 12 — KPI XIRR sur la page Plateformes (2026-07-13)

Rendement annualisé (XIRR) estimé pour les prêts en cours + renommage KPI

Sur InvestissementDetail.jsx, le KPI "Rendement annualisé" a été renommé "XIRR — Brut/Net" et calculé désormais pour tous les statuts (plus seulement rembourse) : pour un prêt non soldé, on ajoute un flux de trésorerie synthétique final = capital restant dû, daté du jour ("valorisation à date"), en plus des flux réels (versement initial, réinvestissements, remboursements). Le label affiche "(estimé)" tant que le prêt n'est pas intégralement remboursé. Un popup (icône info + cell-tooltip tooltip-down) explique la méthodologie. Le modificateur CSS .tooltip-down (top: calc(100% + 6px)) a été ajouté car le tooltip par défaut s'ouvre vers le haut et était coupé en haut de viewport pour ce KPI proche du sommet de page.

Fonction xirr() extraite en utilitaire partagé

Déplacée de InvestissementDetail.jsx (définition locale) vers frontend/src/utils/xirr.js (export nommé xirr), pour réutilisation sans duplication. Newton-Raphson sur flux datés, retourne null si non convergent ou < 2 flux.

Nouveau KPI XIRR sur la page Plateformes

Ajouté en 6e position dans dr-kpi-row de Plateformes.jsx (grid passée de repeat(5,1fr) à repeat(6,1fr)), juste après "Intérêts perçus" — même format que la fiche investissement (label + icône info + tooltip explicatif, "(estimé)" si applicable).

  • Calcul agrégé (plateformeXirr, useMemo) sur chartRows (déjà filtré plateforme + détenteur + année) : flux datés individuels (pas des sommes) — investissement initial (négatif) + réinvestissements (négatif) + remboursements réels (positif, brut = capital+cashback+interets_bruts, net = net_recu), tous filtrés par la même coupure cutoff que les autres agrégats de la page (${selectedYear}-12-31 si une année est sélectionnée, sinon today()).
  • Valorisation à date : pour chaque investissement de chartRows encore actif (capital restant dû > 0 à la coupure), un flux positif = capital restant est ajouté à la date de coupure — cohérent avec la logique déjà utilisée pour totals.encours.
  • Flux triés chronologiquement avant l'appel à xirr() (invariant mathématiquement par rapport à l'ancre t0, mais plus robuste/lisible).
  • Choix assumé : pas de TrendBadge (comparaison N-1) sur ce KPI — contrairement aux 5 autres — car un XIRR n'est pas additif d'une année sur l'autre comme un total cumulé ; à la place, un sous-texte indique la date de valorisation ("Valorisé au 31/12/2025" ou "Valorisé au aujourd'hui").

Piège outillage — reconfirmé une 3e fois (sessions 9, 11, 12)

Le mount bash reste figé sur un instantané qui ne reflète pas les éditions de la session (wc -l sur le mount stagnait à 1591 lignes alors que le fichier réel via l'outil Read en comptait 1675, et ce même après un nouveau cp explicite). Vérification faite intégralement via relecture complète du fichier par l'outil Read (comptage de lignes, relecture des zones éditées, vérification de fermeture des blocs). Ne plus perdre de temps à essayer de resynchroniser le mount bash pour de la vérification syntaxique sur ce projet — se fier à une relecture Read complète et méthodique en priorité.


Session 13 — Objectifs annuels de versement (2026-07-13)

Nouvelle fonctionnalité : suivi d'objectifs de versement annuel

Demande initiale : pouvoir fixer un objectif de dépôts nets par an et suivre l'écart, en partant de la page Plateformes (onglet Dépôts/Retraits), avec une architecture réutilisable ailleurs.

Décisions produit actées après clarification (questions posées avant implémentation, conformément à la règle "poser des questions avant tâche complexe") :

  • Objectif défini par investisseur et par année (pas global, pas par plateforme) — en vue "tous les investisseurs", affichage = somme des objectifs individuels.
  • Le suivi est toujours calculé sur le portefeuille entier (toutes plateformes), indépendamment du filtre plateforme actif sur la page qui l'affiche.

Modèle de données

Nouvelle table objectifs (migration dans db/index.js, avant export default db) :

CREATE TABLE objectifs (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  investisseur_id INTEGER NOT NULL REFERENCES investisseurs(id) ON DELETE CASCADE,
  type TEXT NOT NULL DEFAULT 'versement_annuel',
  annee INTEGER NOT NULL,
  montant REAL NOT NULL,
  notes TEXT,
  created_at/updated_at TEXT,
  UNIQUE(investisseur_id, type, annee)
)

Le champ type est prévu pour réutiliser la table plus tard (ex. 'rendement_annuel') sans nouvelle migration — seul 'versement_annuel' est utilisé actuellement.

Backend

backend/src/routes/objectifs.js (monté sur /api/objectifs dans server.js, entre garantiesRouter et reinvestissementsRouter) : GET / (filtres ?type=&annee=, scope implicite = tous les investisseurs de req.user.id via jointure), POST / (upsert ON CONFLICT(investisseur_id, type, annee)), DELETE /:id. Pattern calqué sur notation.js (pas de middleware requireInvestisseur, ownership vérifiée par jointure SQL).

Composant frontend réutilisable

frontend/src/components/SuiviObjectifs.jsx — props : rows (mouvements bruts dépôts/retraits), investisseurs, scopeInvestisseurIds (tous les ids en vue "all", sinon [activeId]), objectifType (défaut 'versement_annuel'), title. Gère lui-même le fetch/upsert/delete des objectifs. Tableau année par année : Année | Dépôts | Retraits | Différence | Objectif annuel | Écart, + ligne Total en <tfoot> (somme des colonnes sur les années affichées ; Objectif/Écart totaux affichent "—" si aucune année n'a d'objectif défini). Édition : clic sur la cellule Objectif annuel → un input par investisseur du scope (un seul si scope=1), Enregistrer fait un Promise.all de POST upsert (et DELETE si champ vidé). Bouton "+ Ajouter une année" pour anticiper une année future sans mouvement.

Intégration (2 emplacements)

  1. Plateformes.jsx — onglet Dépôts/Retraits, section ajoutée sous le tableau "Mouvements de trésorerie" (rows={allDepots}).
  2. DepotsRetraits.jsx (page dédiée du menu latéral) — onglet dédié "Vision annuelle" dans .dr-tabs, positionné entre "Plateformes" et "Vision mensuelle" (activeTab === 'vision-annuelle', rows={allRows}). Le KPI existant "Diff. Dépôts vs Retraits" est enrichi d'une ligne sous le chiffre : "Reste X € pour l'objectif AAAA" ou "+X € au-delà de l'objectif AAAA" (calcul indépendant du filtre plateforme, année = drPlatYear ou année en cours ; nécessite un fetch local objectifsKpi séparé de celui du composant, car le composant gère son propre state).

Piège outillage — vérification syntaxique de fichiers JSX volumineux

Le mount bash reste périmé (cf. sessions précédentes). Pour vérifier un nouveau composant JSX isolé (pas une édition dans un fichier existant de 1000+ lignes), la méthode fiable trouvée cette session : npm init -y && npm install esbuild dans /tmp (indépendant du node_modules Windows du projet, incompatible avec le sandbox Linux), copier le contenu exact du fichier via cat > fichier << 'EOF' puis esbuild fichier.jsx --bundle --format=esm --jsx=automatic --external:react --outfile=.... Pour une édition ponctuelle dans un gros fichier existant, la relecture Read ciblée des zones modifiées (comptage d'accolades/balises JSX) reste suffisante et plus rapide.


Session 14 — Import IA (prompt + bugs), modale doublons, purge plateforme, job données incomplètes (2026-07-14)

Prompt IA import Remboursements — itérations successives (ImportsSection.jsx, buildReferenceSection/FIELD_HINTS_OVERRIDE)

  1. Plateforme unique : si une seule plateforme existe, le prompt l'assigne directement à toutes les lignes sans poser de question (branche plats.length === 1 dans buildReferenceSection).
  2. Règle CAPITAL vs INTÉRÊTS : distingue une ligne "remboursement mensualité" unique (montant mêlant capital+intérêts) via le ratio (prélèvements sociaux + IR) ÷ montant total : proche de 30 % → intérêts purs ; nettement inférieur → reconstitue interets_bruts ≈ moyenne(prelev_sociaux/0.172, prelev_forfaitaire/0.128) puis capital = total interets_bruts ; aucun prélèvement adjacent → capital pur (échéance finale in fine/différé).
  3. Identification cashback/bonus (ajoutée suite à un cas réel : "Rémunération code cadeau") : reconnue par le libellé (mots-clés "cashback", "bonus", "prime", "code cadeau", "parrainage"...), jamais par l'absence de prélèvement seule (un remboursement de capital pur n'a lui non plus aucun prélèvement adjacent — ne pas confondre). Si le libellé ne référence aucun projet suivi (ex. parrainage global) → ligne exclue du JSON (le module d'import exige un investissement_id).
  4. Règle critique présence systématique des champs : imposer que les 5 champs (capital, cashback, interets_bruts, prelev_sociaux, prelev_forfaitaire) soient toujours explicitement présents sur CHAQUE ligne (valeur 0 si non applicable), jamais omis — corrige un vrai bug de détection (voir ci-dessous).
  5. Suppression de la consigne "génère aussi net_recu" (champ jamais lu par le backend, recalculé côté serveur depuis capital/cashback/intérêts/prélèvements) — source de confusion sans utilité.

🔴 Bug trouvé et corrigé — détection des colonnes basée uniquement sur la 1ère ligne du JSON

  • Symptôme réel : un remboursement de capital final (250 €) importé à 0,00 € partout dans l'app, alors que le JSON source contenait bien "capital": 250.
  • Cause : POST /imports/preview calcule headers = Object.keys(rows[0]) — seule la première ligne du fichier sert à détecter les colonnes disponibles pour l'auto-mapping. Si un champ (ex. capital) est absent de la première ligne (fréquent avec les JSON générés par IA où seules certaines lignes ont telle ou telle info) mais présent plus loin, il n'est jamais mappé pour tout le fichier — donc toujours lu comme 0, silencieusement, même sur les lignes où il est renseigné.
  • Fix : uniquement via le prompt (règle 4 ci-dessus, pas de changement de code) — imposer à l'IA génératrice de toujours inclure tous les champs optionnels sur chaque ligne, à 0 par défaut.

🔴 Bug trouvé et corrigé — champ cashback absent du schéma d'import Remboursements

  • Symptôme réel : une ligne de cashback ("cashback": 2.5) importée avec cashback: 0,00 € alors que tous les autres champs de mapping fonctionnaient.
  • Cause, différente du bug précédent : MODULES.remboursements.optional (ImportsSection.jsx) ne listait jamais cashback parmi les champs du module — ni pour l'auto-mapping (runPreview), ni pour le mapping manuel (<select>), ni pour la liste de champs générée dans le prompt IA (buildFieldsList). Le champ existe pourtant en base et dans le formulaire manuel de saisie. Bug préexistant, pas introduit cette session.
  • Fix : ajout de 'cashback' à MODULES.remboursements.optional.
  • Point d'attention : les lignes déjà importées avec cashback perdu (0 au lieu du vrai montant) ne sont pas corrigées rétroactivement — la détection de doublon remboursements ne compare pas le cashback, donc un ré-import est soit ignoré comme doublon, soit crée une ligne en double si on force l'acceptation. Correction manuelle ligne par ligne recommandée dans ce cas.

🔴 Bug trouvé et corrigé — l'import de remboursements ne reproduit pas la logique de la saisie manuelle

  • Symptôme réel (2 captures d'écran comparées, dev vs prod) : un investissement soldé (capital intégralement remboursé) via import restait affiché en_cours avec un échéancier complet non réajusté (22 échéances futures inchangées), alors que le même remboursement saisi manuellement passait bien l'investissement à rembourse et tronquait l'échéancier aux échéances réellement dues (5 au lieu de 22).
  • Cause : POST /remboursements (saisie manuelle, routes/remboursements.js) appelle après chaque insertion syncInvestissementStatut() (passe rembourse si capital total remboursé ≥ montant investi + réinvestissements) et adjustSimulForActuals() (tronque/recalcule les échéances futures devenues caduques en cas de remboursement anticipé). POST /imports/apply (module remboursements) ne faisait qu'un INSERT brut en boucle, sans jamais appeler ces deux fonctions.
  • Fix : syncInvestissementStatut exportée depuis remboursements.js ; imports.js importe cette fonction + adjustSimulForActuals (déjà exportée de schedule.js) ; après le commit de la transaction d'import (module remboursements), boucle best-effort sur tous les investissement_id touchés par l'import pour appeler les deux fonctions — reproduit exactement le comportement de la saisie manuelle.
  • Règle à retenir : toute route d'import en masse qui insère directement dans une table déjà pourvue d'effets de bord post-insertion (recalcul de statut, régénération d'échéancier...) côté route manuelle doit rejouer ces mêmes effets de bord après le commit — ne pas se contenter du simple INSERT.

Modale de revue des doublons à l'import (tous modules)

  • Nouvelle route POST /imports/check-duplicates (dry-run, même détection que /apply mais sans écriture) + duplicateDecisions accepté par /apply ({ [rowNum]: 'accept'|'skip' }).
  • Détection des doublons internes au fichier (pas seulement contre la base) via des Map en mémoire (seenDepotsRetraits, seenInvestissements, etc.) qui simulent l'effet séquentiel d'une transaction SQLite réelle (une ligne répétée plus loin dans le même fichier est comparée aux lignes déjà "vues", pas seulement à la base).
  • UI (ImportsSection.jsx) : ligne = case à cocher + titre "Doublon de données repéré en ligne X avec celles de la ligne Y (ou un enregistrement déjà en base)", détail replié par défaut (chevron), boutons "Cocher/Décocher tous les doublons".
  • Pièges CSS rencontrés (styles.css a des sélecteurs globaux label/input qui fuient sur du HTML brut) : label { text-transform: uppercase; ...} et input,select,textarea { width:100%; padding:7px 10px; ...} s'appliquent même à une checkbox de ligne — toujours réinitialiser explicitement en inline style (textTransform:'none', width:14, height:14, padding:0, flexShrink:0), pattern déjà présent ailleurs (.cat-select-item input[type="checkbox"]). .modal-overlay/.modal/.modal-header étaient absentes de styles.css (seule .modal-backdrop existait, pour le composant Modal.jsx partagé) — ajoutées, corrige aussi rétroactivement les modales ad-hoc de DataCleanupSection.jsx.
  • Après import réussi : le textarea "Analyser les données" (IA) se vide et la page recharge automatiquement (message de résultat conservé via sessionStorage le temps du reload, réaffiché au montage).

Job horaire — données essentielles manquantes sur les investissements

  • Nouveau backend/src/jobs/checkDonneesIncompletes.js, démarré dans server.js (startCheckDonneesIncompletesJob, pattern horaire identique à autoTicketStatus.js).
  • Vérifie sur chaque investissement statut != 'cloture' : taux_interet, duree_mois, type_remb, date_premiere_echeance.
  • Anti-doublon de notification : nouvelle colonne investissements.donnees_incompletes_signature (migration) stockant la liste triée des champs actuellement manquants. Notification renvoyée seulement si cette signature change (nouveau manque, ou toujours incomplet mais différemment) ; réinitialisée silencieusement (sans notif) quand tout redevient complet.
  • Même fonction rejouée en best-effort à la fin d'un import investissements réussi (imports.js, après le commit de la transaction).
  • Scope volontairement limité à ces 4 champs (portée assumée sans élargir sur le "etc." de la demande initiale).

Suppression de données d'une plateforme (DataCleanupSection.jsx + POST /plateformes/:id/purge-donnees)

  • Scopes : toutes les données / dépôts-retraits / investissements (+ remboursements liés en cascade) / remboursements uniquement. La fiche plateforme n'est jamais supprimée.
  • Confirmation par PIN à 6 chiffres (remplace le "retapez le nom de la plateforme") : PIN aléatoire généré à l'ouverture de la modale (Math.floor(100000 + Math.random()*900000)), affiché en gros (monospace 30px, espacé, rouge) ; le payload envoyé à l'API contient toujours confirmNom: purgePlat.nom en interne (le PIN est une couche de confirmation UI uniquement, la vérification serveur par nom exact est inchangée).
  • Sélecteur de plateforme corrigé pour le multi-détenteur : deux plateformes de familles différentes peuvent porter le même nom (ex. deux comptes "Enky"). Fix en reprenant le pattern déjà établi ailleurs (const multiDetenteurPlats = new Set(plats.map(p => p.investisseur_id)).size > 1, suffixe — {investisseur_nom} dans les <option> et rappel dans le texte de la modale, uniquement si multi-détenteur) — cf. session 9 "Colonne Détenteur — masquée si mono-détenteur", même pattern à répliquer sur tout futur select de plateformes.