Initial commit

This commit is contained in:
Olivier CROGUENNEC
2026-06-13 14:57:15 +02:00
commit 48ed7fe65e
209 changed files with 49979 additions and 0 deletions
+384
View File
@@ -0,0 +1,384 @@
# 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.