Files
Olivier CROGUENNEC 48ed7fe65e Initial commit
2026-06-13 14:57:15 +02:00

385 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# MEMORY.md — Crowdlending Tracker
*Dernière mise à jour: 2026-05-09 (session 6)*
---
## 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 && <div className="error">{err}</div>}
```
### Modales
```jsx
<Modal open={bool} title="…" onClose={fn} footer={<></>} width={680}>
```
### Pas de couleurs hardcodées
Toujours `var(--primary)`, `var(--success)`, `var(--danger)`, `var(--text)`, `var(--text-muted)`, `var(--border)`, `var(--surface-2)`, etc.
---
## Types / énumérations de référence
| Entité | Valeurs |
|---|---|
| `investissements.statut` | `en_cours` \| `rembourse` \| `en_retard` \| `procedure` \| `cloture` |
| `investissements.type_remb` | `in_fine` \| `amortissable` \| `differe` |
| `investissements.freq_interets` | `mensuel` \| `trimestriel` \| `in_fine` |
| `remboursements.type` | `normal` \| `bonus_parrainage` \| `bonus_plateforme` |
| `remboursements.statut` | `paye` \| `retard` \| `partiel` \| `impaye` |
| `depots_retraits.type` | `depot` \| `retrait` |
| `investisseurs.type` | `famille` \| `entreprise` |
| `users.role` | `user` \| `admin` |
---
## Sections Settings
| Section (`?section=`) | Contenu |
|---|---|
| `apparence` (défaut) | thème, police, langue, devise |
| `plateformes` | CRUD plateformes + catégories |
| `categories` | CRUD catégories de plateformes |
| `garanties` | référentiel garanties |
| `pfu` | taux PFU par année |
| `notation` | critères notation par plateforme |
| `imports` | import CSV/XLS |
---
## Endpoints API principaux
```
POST /api/auth/register | login | me
GET/POST/PUT/DELETE /api/investisseurs
GET/POST/PUT/DELETE /api/plateformes
GET/POST/PUT/DELETE /api/depots-retraits
GET/POST/PUT/DELETE /api/investissements
GET/POST/PUT/DELETE /api/remboursements
GET/POST/DELETE /api/simul
POST /api/simul/generate
GET /api/dashboard
GET /api/fiscal-2778?annee=YYYY
GET /api/fiscal-2778/export?annee=YYYY
GET/POST /api/pfu
GET/POST/PUT/DELETE /api/notation
GET/POST/PUT/DELETE /api/garanties
GET/POST/PUT/DELETE /api/categories
POST /api/imports/preview | apply
GET /api/imports/history
GET/POST/PUT/DELETE /api/admin/users (requireAdmin)
```
Headers requis (hors `/auth/*`) : `Authorization: Bearer <jwt>` + `X-Investisseur-Id: <id>` pour routes scopées.
---
## Démarrage local
```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.