# MEMORY.md — Crowdlending Tracker
*Dernière mise à jour: 2026-07-13 (session 13)*
---
## 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_debut` → `date_premiere_echeance`, `date_echeance` → `date_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
```js
api.get('/investissements', { scope: 'all' })
api.post('/remboursements', payload)
api.put(`/investissements/${id}`, payload)
api.del(`/plateformes/${id}`)
```
### Formatage
```js
import { fmtEUR, fmtPct, fmtDate, fmtStatut, memberLabel, today } from '../utils/format.js'
```
### Erreurs form
```js
const [err, setErr] = useState(null)
// {err &&
{err}
}
```
### Modales
```jsx
…>} 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 ` + `X-Investisseur-Id: ` pour routes scopées.
---
## Démarrage local
```bash
# 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)
```js
// 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) :
```jsx
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
```js
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** : `allRows` → `kpiRows` (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/general` → `backend/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` → `` 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 `` 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'`` interne ; `wrapperStyle` optionnel pour le `
` 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 `` plutôt que `` 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 `
`/`
` 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 `` 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_cours` ↔ `en_retard`) sur les mêmes prêts toutes les quelques minutes.
- **Cause réelle** (migration "renommage `date_debut`→`date_premiere_echeance`, `date_echeance`→`date_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/recalculate` → `adjustSimulForActuals` → `generateSimulWithReinvestissements`, 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_interet` → `inv.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`) :
```sql
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 `` (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.