22 KiB
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é localStoragecl_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
InteretsCharta été supprimé — reçoitnetModeen 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_europeennefiscalite:flat_tax|sans_fiscalite_locale|avec_fiscalite_localemethode_remboursement:portefeuille|compte_courant|choix_investisseurportefeuille(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'→fiscaliteforcé àflat_tax,taux_fiscalite_localenull - 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_idvia JWT
Patterns à respecter
API calls
api.get('/investissements', { scope: 'all' })
api.post('/remboursements', payload)
api.put(`/investissements/${id}`, payload)
api.del(`/plateformes/${id}`)
Formatage
import { fmtEUR, fmtPct, fmtDate, fmtStatut, memberLabel, today } from '../utils/format.js'
Erreurs form
const [err, setErr] = useState(null)
// {err && <div className="error">{err}</div>}
Modales
<Modal open={bool} title="…" onClose={fn} footer={<>…</>} width={680}>
Pas de couleurs hardcodées
Toujours var(--primary), var(--success), var(--danger), var(--text), var(--text-muted), var(--border), var(--surface-2), etc.
Types / énumérations de référence
| Entité | Valeurs |
|---|---|
investissements.statut |
en_cours | rembourse | en_retard | procedure | cloture |
investissements.type_remb |
in_fine | amortissable | differe |
investissements.freq_interets |
mensuel | trimestriel | in_fine |
remboursements.type |
normal | bonus_parrainage | bonus_plateforme |
remboursements.statut |
paye | retard | partiel | impaye |
depots_retraits.type |
depot | retrait |
investisseurs.type |
famille | entreprise |
users.role |
user | admin |
Sections Settings
Section (?section=) |
Contenu |
|---|---|
apparence (défaut) |
thème, police, langue, devise |
plateformes |
CRUD plateformes + catégories |
categories |
CRUD catégories de plateformes |
garanties |
référentiel garanties |
pfu |
taux PFU par année |
notation |
critères notation par plateforme |
imports |
import CSV/XLS |
Endpoints API principaux
POST /api/auth/register | login | me
GET/POST/PUT/DELETE /api/investisseurs
GET/POST/PUT/DELETE /api/plateformes
GET/POST/PUT/DELETE /api/depots-retraits
GET/POST/PUT/DELETE /api/investissements
GET/POST/PUT/DELETE /api/remboursements
GET/POST/DELETE /api/simul
POST /api/simul/generate
GET /api/dashboard
GET /api/fiscal-2778?annee=YYYY
GET /api/fiscal-2778/export?annee=YYYY
GET/POST /api/pfu
GET/POST/PUT/DELETE /api/notation
GET/POST/PUT/DELETE /api/garanties
GET/POST/PUT/DELETE /api/categories
POST /api/imports/preview | apply
GET /api/imports/history
GET/POST/PUT/DELETE /api/admin/users (requireAdmin)
Headers requis (hors /auth/*) : Authorization: Bearer <jwt> + X-Investisseur-Id: <id> pour routes scopées.
Démarrage local
# Backend
cd backend && npm install && npm run dev # :4000
# Frontend
cd frontend && npm install && npm run dev # :5173 (proxy /api → 4000)
Variables d'env : copier .env.example → .env, renseigner JWT_SECRET.
Fichiers clés à connaître
| Fichier | Rôle |
|---|---|
backend/src/db/index.js |
Init DB + toutes les migrations |
backend/src/db/schema.sql |
Schéma de référence (CREATE TABLE) — ne pas modifier pour les évolutions |
frontend/src/api.js |
Client HTTP centralisé (gère JWT) |
frontend/src/utils/format.js |
fmtEUR, fmtDate, fmtPct, fmtStatut, memberLabel, today |
frontend/src/context/UiContext.jsx |
displayMode (brut/net), sidebar, fontScale, langue, devise |
frontend/src/components/Layout.jsx |
Shell app, topbar, toggle Brut/Net |
frontend/src/pages/Settings.jsx |
Hub paramètres, helpers fiscalité plateforme |
Layout deux colonnes — pattern Settings (session 2026-05-08)
Les sections Plateformes et Catégories d'investissement de Settings.jsx utilisent désormais le même pattern deux colonnes que DepotsRetraits.jsx :
- Classes CSS réutilisées :
.dr-mouvements-layout,.dr-mouvements-list,.dr-mouvements-detail,.dr-row,.dr-row-selected,.dr-detail,.dr-detail-empty,.dr-detail-title,.dr-detail-fields,.dr-detail-field,.dr-detail-label,.dr-detail-value,.dr-detail-footer,.dr-detail-edit-btn - Plateformes : tableau réduit (Nom + Catégories d'invest. + Nb invest.), panneau détail droite avec
PlatDetailPanel, création via modaleshowNewPlat, suppression dans la modale d'édition (bouton "Supprimer" dans le footer gauche de la modale — logique inline avecconfirm()+ early return si annulé) - Catégories : tableau réduit (Nom + Nb plateformes), panneau détail droite avec
CatDetailPanel, création via modaleshowNewCat, 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 :
useEffectavecsetSelectedX(prev => prev ? prev : items[0]) nb_investissementsajouté au backend dansGET /api/plateformesvia 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() :
triggerRefsur le bouton déclencheuruseLayoutEffectmesurerect.bottom + 4/rect.left/rect.widthà chaque ouverture- Dropdown rendu en dehors du
.cat-select-wrap(comme sibling dans le<>...</>) avecid="cat-select-dropdown-portal"etzIndex: 9999 - Click-outside exclut à la fois
wrapRefet le portal scroll(capture phase) +resizeferment 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 INTEGERajoutée surinvestissements(migration dansdb/index.js), FK verscategories_plateforme(id) ON DELETE SET NULL - Backend retourne
categorie_nomviaLEFT JOIN categories_plateforme cp ON cp.id = i.categorie_iddansGET /investissementsetGET /investissements/:id - Schema Zod :
categorie_id: z.number().int().positive().nullable().optional()
Logique de sélection par défaut (à respecter partout)
// 0 catégories liées à la plateforme → CategorySelect libre (toutes cats, mode single-select)
// 1 catégorie → auto-sélectionnée, select disabled
// 2+ catégories → select activé, défaut = la plus utilisée dans rows (user-wide), sinon première
function defaultCategorieId(platId, plats, rows = []) {
const plat = plats.find(p => p.id === Number(platId));
if (!plat || !plat.categories || plat.categories.length === 0) return '';
if (plat.categories.length === 1) return plat.categories[0].id;
const counts = {};
for (const r of rows) {
if (r.categorie_id && plat.categories.some(c => c.id === r.categorie_id))
counts[r.categorie_id] = (counts[r.categorie_id] || 0) + 1;
}
if (Object.keys(counts).length > 0)
return Number(Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0]);
return plat.categories[0].id;
}
- Dans
InvestissementDetail.jsx: version sansrows(pas de liste disponible), 2+ → première de la liste InvestissementDetail.defaultCategorieId: signature(platId, plats)seulement
Ouverture de modale édition — règle critique
Toujours initialiser categorie_id avec row.categorie_id || defaultCategorieId(...) et NON row.categorie_id || ''. Sans ça, les anciens investissements (categorie_id null en base) affichent la bonne catégorie visuellement (select disabled sur 1 seul choix) mais envoient null au backend car l'état React reste à ''.
CategorySelect en mode single-select
Quand CategorySelect est utilisé pour un champ à valeur unique (ex : categorie_id) :
selected={form.categorie_id ? [Number(form.categorie_id)] : []}
onChange={ids => setForm(f => ({ ...f, categorie_id: ids[ids.length - 1] ?? '' }))}
ids[ids.length - 1] fonctionne car toggle() ajoute à la fin — unchecking donne [], switching donne [ancien, nouveau].
Fichiers modifiés
| Fichier | Changement |
|---|---|
backend/src/db/index.js |
Migration categorie_id sur investissements |
backend/src/routes/investissements.js |
Schema + GET/POST/PUT avec categorie_id/categorie_nom |
frontend/src/pages/Investissements.jsx |
Import CategorySelect, état categories, defaultCategorieId, champ dynamique modale |
frontend/src/pages/InvestissementDetail.jsx |
Idem + affichage dans "Informations du projet" |
Bonus Parrainage / Bonus Plateforme (session 3 — 2026-05-08)
Modèle de données
remboursements.type:'normal'|'bonus_parrainage'|'bonus_plateforme'- Pour type
normal:investissement_idobligatoire,bonus_plateforme_idnull - Pour les bonus :
bonus_plateforme_idobligatoire,investissement_idnull,bonus_investisseur_idpour le scope - Seul champ saisi pour les bonus :
cashback(stocké aussi dansnet_recu)
Frontend — sentinelles dans la modale Remboursements
const BONUS_VALUES = ['BONUS_PARRAINAGE', 'BONUS_PLATEFORME'];
const BONUS_TYPE_MAP = { BONUS_PARRAINAGE: 'bonus_parrainage', BONUS_PLATEFORME: 'bonus_plateforme' };
const isBonus = BONUS_VALUES.includes(form.investissement_id);
- Options ajoutées au select investissement :
value="BONUS_PARRAINAGE"etvalue="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: mapper.type→ sentinel pour pré-remplir le select
Backend — route remboursements
- GET :
LEFT JOIN investissements+COALESCEpour résoudreplateforme_idetinvestisseur_id - Alias critique :
COALESCE(i.plateforme_id, r.bonus_plateforme_id) AS plateforme_id(pasresolved_plateforme_id— le frontend utiliser.plateforme_idpartout 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: filtreAND r.type = 'normal'pour exclure les bonus
Migration DB (db/index.js)
- Recréation complète de la table
remboursementsvia table temporaire__repair_remboursements - Guard :
!rembColsBonus.includes('bonus_plateforme_id') PRAGMA foreign_keys = OFFavant,ONaprès- Drop/recreate de la vue
v_interets_annuels(qui référenceinvestissement_id)
Problèmes de compatibilité Node.js rencontrés (session 3)
||=(logical OR assignment) dansplateformes.jscausaitSyntaxError: Unexpected token ']'sur Node v20.17.0 → remplacé parif (!map[x]) map[x] = []db/index.jstronqué : les outils Edit/Write etcat >>tronquent parfois les fichiers longs. Solution fiable : reconstruire avecpython3(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 tailouwc -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)) pourbonus_parrainage, badge "Bonus" (vert,var(--success)) pourbonus_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.nomcontient déjà le nom complet (ex. "Olivier CROGUENNEC"), etinv.prenomvaut "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.memberLabelretourne simplementm.nom. - Règle à retenir : toujours utiliser
memberLabel(inv)pour afficher le nom d'un investisseur dans les options/selects — jamaisinv.prenom + inv.nom.
Session 6 — UX et calculs (2026-05-09)
Barre de recherche rapide de projets (Layout.jsx)
- Composant
ProjectSearchajouté dans la topbar globale (Layout.jsx) - Ctrl+K / Cmd+K → focus sur la barre
- Résultats filtrés (max 8) sur
nom_projetetplateforme_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_LABELSdansProjectSearch:{ 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èsapi.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) totalscalculés depuiskpiRowspour que les KPI restent dynamiquesplatYear/rembPlatYear/drPlatYear: états indépendants defilter.year— pour le sélecteur d'année du tableau par plateforme uniquement- DepotsRetraits :
kpiSoldePortefeuillecalculé live depuis backend (sans filtre année) OU cumulatif depuisallRows + allRembjusqu'à${drPlatYear}-12-31(avec filtre année, inclut les remboursementsmethode=portefeuille) - KPI sur une seule ligne
.dr-kpi-rowen 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èsgenerateSimulpour le castotal_capital <= 0) - Logique : trouve le 1er remboursement réel avec
interets_bruts > 0→ cherche l'entrée simul du même mois YYYY-MM → siinterets_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
net_recu_totaldans Investissements =SUM(r.net_recu)= capital + cashback + intérêts nets — c'est une approximation du rendement net, pas uniquement les intérêts.- XIRR : calculé uniquement sur
statut === 'rembourse'. Flux bruts = capital + cashback + interets_bruts. Flux nets = net_recu. InteretsChartn'a plus de toggle interne — toujours passernetModeen prop depuis le parent.- Migrations DB : guard
PRAGMA table_info()obligatoire pour éviter les erreurs si colonne déjà présente. - Admin : middleware
requireAdminsur/api/admin. Lien visible dans UserMenu uniquement siisAdmin. - Job auto-statut :
backend/src/jobs/autoStatut.js— met à jour les statuts investissements automatiquement. - Modal + dropdown : ne jamais utiliser
position: absolutepour un dropdown dans une modale — utiliserposition: fixed+getBoundingClientRect()(cf.CategorySelect.jsx). - Suppression dans modale : ne pas chaîner
.then(() => closeModal())sur une fonction qui appelleconfirm()— inliner la logique avec early return si!confirm(...)pour éviter la fermeture sur "Annuler". - Affichage investisseur : toujours
memberLabel(inv)— jamaisinv.prenom + ' ' + inv.nomcarnomcontient déjà le nom complet.