# MEMORY.md — Crowdlending Tracker *Dernière mise à jour: 2026-07-03 (session 8)* --- ## 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/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` ### 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`).