50 KiB
MEMORY.md — Crowdlending Tracker
Dernière mise à jour: 2026-07-13 (session 11)
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.
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,DetailTooltipJSON au survol
Bug focus-loss sur InvitationRegister
- Cause : composant
Wrapdéfini à l'intérieur du corps deInvitationRegister()→ new component type à chaque render → React démonte/remonte tous les enfants → perte de focus à chaque frappe - Fix : déplacer
Wrapen dehors de la fonction, passerappInfoen 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
lendynamique viabuildRules(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-infoenrichie : exposeallowRegistrationetminPasswordLength(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→<Navigate to="/login" replace />siallowRegistration=falseLogin.jsx: lien "Créer un compte" conditionnel surappInfo.allowRegistration !== false- Fetch
/api/app-infoau montage dans App.jsx, état initialallowRegistration: true(jamais bloquant par défaut)
minPasswordLength — propagation
- Chaque écran password fetch
/api/app-infoau montage et extraitminPasswordLength 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: proptopar défaut passée de/settings?section=plateformesà/settings?section=plateformes&openAdd=1PlateformesSection.jsx:useSearchParams+useEffectdétecteopenAdd=1au montage → ouvre directementshowAddPicker(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é +
useEffectde consommation/nettoyage dans la section cible
Composant PasswordInput — afficher/masquer mot de passe
- Nouveau composant
frontend/src/components/PasswordInput.jsx: wrapper autour d'un<input type="password">avec bouton œil (SVG inline, pas de lib externe) togglanttypeentrepassword/text - API : toutes les props (
className,value,onChange,required,autoComplete,minLength,style, etc.) sont transmises telles quelles à l'<input>interne ;wrapperStyleoptionnel pour le<div style="position:relative">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
<PasswordInput>plutôt que<input type="password">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 }) dansauth.js: vérifie le mot de passe (bcrypt), bloque si l'utilisateur est le dernier admin (COUNT(*) WHERE role='admin' <= 1), logge un auditaccount_self_deleted(catégorieaccount,details.initiated_by:'self') avant la suppression, notifie tous les autres admins (typesecurity, lien/admin?section=audit-logs), puisDELETE 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 CASCADEdéjà en place suruser_id/investisseur_id/etc. (db.pragma('foreign_keys = ON')activé globalement dansdb/index.js). C'est le même mécanisme queDELETE /api/admin/users/:id(admin.js) qui fait déjà un simpleDELETE FROM userssans étape de nettoyage manuel audit_logs.actor_id/target_user_idsont enON DELETE SET NULL(pas CASCADE) : le log survit à la suppression de l'utilisateur, les infos identifiantes (email, display_name, role) sont dupliquées dansdetailsJSON pour rester lisibles même une fois les FK à NULL- Frontend :
AuthContext.deleteAccount(password)→api.del('/auth/me', {password})puislogout();api.delaccepte maintenant unbodyoptionnel (api.js) DeleteAccountSectiondansMonCompte.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.jsxaffiche 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 sansis_principal=1et sans compte courantPOST /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 maisis_principal=0et zéro compte - Fix appliqué :
admin.js(POST /users) etinvitations.js(POST /:token/register) répliquent maintenant exactement la logique deauth.js:INSERT INTO investisseurs (..., is_principal) VALUES (..., 1)+INSERT INTO comptes (user_id, nom, type, investisseur_id)avecnom = 'Compte courant — ' + fullName- Backfill idempotent ajouté en fin de
backend/src/db/index.js(avantexport 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 investisseurfamillepour 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 utilisentNOT 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(investisseursavecis_principal=1+comptestypecompte_courant) — ne pas dupliquer seulement l'insertinvestisseurs.
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;(ouinvestisseurs.length > 1si pas de liste de plateformes disponible) - Déployé sur :
Investissements.jsx,Remboursements.jsx,Plateformes.jsx,PlateformesSection.jsx,ComptesSection.jsx,DepotsRetraits.jsx— colonnes<th>/<td>conditionnées +colSpanajusté + tfoot spacer - Idem sur les exports (XLS/CSV/JSON) : passer
multiDetenteuren 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()ouvrecorrModal(vérification du solde déclaré) au lieu d'appelerload()directement ; l'early-return de l'empty-state (!loading && !modalOpen && allRows.length === 0) masquait la<CorrectionModal>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 vianormalizeName) pourplateforme_id(modulesdepots_retraits/investissements) etinvestissement_id(moduleremboursements) - 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_souscriptionremboursements: investissement + date_remb + capital + interets_bruts (tolérance 0,005)plateformes: déjà couvert parUNIQUE(nom)→ reclassé en doublon plutôt qu'erreur
- Compteur
duplicatesdistinct deskipped(vraies erreurs), colonne DBimports.rows_duplicates, affiché dans l'historique
Import — anomalie date antérieure à la date d'ouverture de la plateforme
- Pour
depots_retraits/investissementsuniquement (pasremboursements, 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
/applyinclutanomalies: [{ 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 lePUT /:idcomplet) ImportsSection.jsxaffiche 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 fencejson) qui est réinjecté dans le pipeline preview/apply existant via unBlob/Filevirtuel (pas de code dupliqué) - Règle imposée au prompt : si le fichier ne permet pas d'identifier avec certitude la plateforme (ou l'investissement pour les remboursements), l'IA doit poser la question à l'utilisateur en ne proposant QUE les noms existants comme réponses possibles, et attendre la réponse avant de générer le JSON — plutôt que de deviner ou d'omettre le champ silencieusement
- Page restructurée en 3 blocs séquentiels : "1. Contexte de l'import" (sélecteur de module) → "2. Fichier source" → "3. Mappage des colonnes" ; le bloc "Dossier investissement" n'apparaît que si le module actif est
investissements
Bug — suppression de compte cassée par plateforme_id ON DELETE RESTRICT
- Symptôme :
DELETE /api/auth/meéchouait avecSqliteError: FOREIGN KEY constraint failed(codeSQLITE_CONSTRAINT_TRIGGER) àauth.js:319. - Cause :
depots_retraits.plateforme_idetinvestissements.plateforme_idétaient enON DELETE RESTRICT(seuls FK RESTRICT du schéma) — lors duDELETE FROM users, les branches de cascadeusers→plateformesetusers→investisseurs→depots_retraits/investissementssont indépendantes ; SQLite peut supprimer la plateforme avant les lignes qui la référencent encore, déclenchant RESTRICT. - Fix : migration
fixPlateformeCascadedansdb/index.js(recréation table via__repair_*, RESTRICT→CASCADE) +schema.sqlmis à jour + protection déplacée côté application dansDELETE /api/plateformes/:id(vérification explicite du nombre d'investissements/dépôts-retraits avant suppression, message clair). - Règle à retenir : toute nouvelle table référençant
plateformes/investisseurs/usersdoit être enCASCADE(ouSET NULL), jamaisRESTRICT— sinon la suppression de compte se recasse. Les protections anti-suppression-accidentelle doivent être implémentées côté route, pas côté contrainte FK.
Piège outillage — mount bash périmé après édition Edit/Write
- Après une édition via l'outil
Edit/Write(côté fichier réel), une vérification immédiate viamcp__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. reconstructionpython3après troncature confirmée parRead).
Session 10 — Bug racine dates prêts différés + audit trail + statuts auto (2026-07-12)
🔴 Bug racine trouvé et corrigé — migration db/index.js recalculait date_cible à CHAQUE démarrage
- Symptôme initial : des dizaines de prêts
differeavecdate_cibleaberrante (années 2100 à 2650), sans aucune trace dansinvestissement_historique, y compris des cas de "ping-pong" de statut (en_cours↔en_retard) sur les mêmes prêts toutes les quelques minutes. - Cause réelle (migration "renommage
date_debut→date_premiere_echeance,date_echeance→date_cible",backend/src/db/index.js~ligne 229) : le bloc de "correction de formule historique" (SET date_cible = date(date_premiere_echeance, '+(duree_mois-1)' months)) n'avait aucune garde (WHERE date_cible IS NULL) — il tournait donc à chaque redémarrage serveur, pour tous les investissements. Combiné à une 2e migration juste après (date_premiere_echeance = date_ciblepour les prêts différés, censée maintenir l'égalité des deux dates), cela formait une boucle infinie : à chaque redémarrage,date_cibledérivait de +(duree_mois-1) mois supplémentaires — jamais tracé, car du code de migration, pas une action utilisateur ni une route API. - Pourquoi ça n'a été détecté que maintenant : le serveur redémarre très souvent en dev (
node --watch), donc chaque édition de code déclenchait un cycle de dérive supplémentaire sur les prêts différés déjà touchés. - Fix : le bloc de correction ne s'exécute désormais que si
migrationEnCoursest vrai (colonnesdate_debut/date_echeanceencore présentes, càd le jour réel du renommage) — plus jamais au démarrage normal. La migrationdate_premiere_echeance = date_cible(prêts différés) enregistre maintenant un historique précis (correction_auto_echeancier) quand elle modifie quelque chose. - Point d'attention pour le futur : toute migration de données (pas juste
ALTER TABLE ADD COLUMN) dansdb/index.jsdoit être strictement idempotente/one-shot — soit via clauseWHERE champ IS NULL, soit gardée derrière la détection de l'événement historique qui la justifie. Ne jamais laisser unUPDATEsans garde tourner à chaque boot.
Autres endroits déjà audités/corrigés pour la même classe de bug (dates modifiées sans trace)
POST /api/imports/dossier(imports.js) : la branche "SCÉNARIO UPDATE" (upsert par nom_projet+date_souscription) écrasaitdate_cible/date_premiere_echeance/montant_investi/taux_interet/duree_mois/type_remb/statutavec seulement un historique générique ("Mise à jour dossier"), sans le détail des champs modifiés. Fix : réutilisedetectChangements/recordHistory/detectTypeEvenement(désormais exportés depuisinvestissements.js) pour logger un diff précis champ par champ, comme l'édition manuelle.backend/fix_dates_cible.mjs: script autonome (node fix_dates_cible.mjs), jamais appelé automatiquement, corrige lesdate_cible > 2100-01-01— toujours présent mais pas la source du bug de cette session.
Nouveautés Nettoyage de données (Settings.jsx → section imports, composant DataCleanupSection.jsx)
- "Corriger les dates des prêts différés" (
POST /investissements/fix-differe-dates) :- Seuil d'écart désormais configurable via un select dans la modale : 3 / 6 / 12 / 18 / 24 mois (défaut 24, avant en dur "2 ans"). Body
{ seuilMois }, validé côté backend contre[3,6,12,18,24]. - Régénère maintenant
simul_remboursementsviagenerateSimul()après correction (avant : échéancier laissé désynchronisé). - Déclenche immédiatement
checkStatutsRetard()après correction (sinon un prêt dont la date recalculée tombe dans le passé restait affiché "en_cours" jusqu'au prochain minuit).
- Seuil d'écart désormais configurable via un select dans la modale : 3 / 6 / 12 / 18 / 24 mois (défaut 24, avant en dur "2 ans"). Body
- "Vérifier la cohérence de l'échéancier des prêts différés" (nouveau,
POST /investissements/check-echeancier-differe) — inséré juste après le précédent. Vérifie quesimul_remboursementscontient exactement 1 échéance à la datedate_premiere_echeance/date_cibledu prêt ; régénère sinon (generateSimulWithReinvestissementssi réinvestissements présents, sinongenerateSimul), logcorrection_auto_echeancier.
Job autoStatut.js — statuts automatiques enrichis
checkStatutsRetard()fait maintenant les deux sens :en_cours→en_retard(comme avant, logpassage_auto_retard) ETen_retard→en_cours(nouveau, logretour_auto_en_cours) — mais uniquement si (a)date_cibleest entre aujourd'hui et +30 ans (garde-fou anti date-encore-aberrante-mais-"future") ET (b) le dernier événement d'historique touchant le statut était bienpassage_auto_retard(jamais d'annulation automatique d'un passage en retard décidé manuellement).- Chaque transition génère une notification utilisateur (table
notifications) : typewarningpour le passage en retard, typesuccesspour le retour en cours, avec lien direct/investissements/:id. TRACKED_FIELDS,recordHistory,detectChangements,detectTypeEvenementsont maintenantexportdepuisinvestissements.js(réutilisés parimports.js).
Piège outillage — lecture de la DB SQLite en direct depuis le sandbox bash (WAL + serveur actif)
- Le fichier réel
backend/data/crowdlending.dbest en mode WAL et activement écrit par le serveur Node de l'utilisateur pendant la session. Une copie manuelle (cpséparé du.db/.db-wal/.db-shm) pendant que le serveur écrit produit des lectures tronquées ("database disk image is malformed") ou incohérentes entre deux requêtes successives — ce n'est PAS la preuve que les données changent réellement à chaque lecture. better-sqlite3du repo est un binaire natif Windows (node_modules/better-sqlite3/build/Release/better_sqlite3.node) →invalid ELF headerdans le sandbox Linux. Utiliserpython3+ modulesqlite3standard à la place, sur une copie locale dans/tmp.- Pour une lecture fiable d'un état ponctuel : privilégier les preuves de haut niveau déjà présentes dans l'app (page Historique du prêt, page Notifications avec horodatage) plutôt que des requêtes SQL répétées sur une DB en cours d'écriture concurrente.
Session 11 — Révision des conditions de prêt (2026-07-13)
Nouvelle fonctionnalité : révision des conditions (taux / date cible)
Distincte de investissement_historique (audit générique auto-détecté sur tout changement de champ) : trace un événement métier explicite (retard projet, renégociation…) avec motif obligatoire, dans sa propre table.
- Table
investissement_revisions(migration dansdb/index.js) :date_effet,ancien_taux/nouveau_taux,ancienne_date_cible/nouvelle_date_cible,ancien_duree_mois/nouveau_duree_mois,motif TEXT NOT NULL. - Routes dans
investissements.js(pas de nouveau fichier) :POST /:id/revisions,DELETE /:id/revisions/:rid(seule la dernière révision est supprimable, rollback vers l'état précédent + régénération de l'échéancier).GET /:idretournerevisionsau même niveau quehistorique/simul. - Point technique clé :
generateSimul()se base surduree_mois, pas surdate_cible(champ d'affichage/contractuel). Donc sinouvelle_date_cibleest fournie,duree_moisest recalculé (monthsDiff(date_premiere_echeance, nouvelle_date_cible) + 1) sinon la nouvelle date cible ne serait que cosmétique. Ancien/nouveauduree_moistracés en base pour un rollback fidèle. - Helper
regenererEcheancier(investissementId)dansinvestissements.js: appellegenerateSimulWithReinvestissementssi l'investissement a des réinvestissements, sinongenerateSimul. - Frontend
InvestissementDetail.jsx: modal "Réviser les conditions" avec 2 checkboxes explicites ("Modifier le taux" / "Modifier la date cible") — pas de convention implicite "champ vide = inchangé" (ambiguë, corrigée suite à retour utilisateur). Carte "Révisions du prêt" placée juste après "Informations du projet", avant "Remboursements enregistrés" (positionnement demandé explicitement par l'utilisateur). Badges "Révisé le" avec ancien barré → nouveau sur les champs Taux annuel / Date cible contractuelle. Avertissement non bloquant (texte violet#a78bfa) dans le formulaire "Modifier" standard si le champ a déjà été révisé — n'empêche pas l'édition directe (choix utilisateur : le formulaire standard reste une simple correction de fiche, pas un événement de révision).
🔴 Bug trouvé et corrigé — generateSimulWithReinvestissements ne préservait pas les échéances déjà payées
- Symptôme : après une révision sur un prêt avec réinvestissement actif, la table "Projections de remboursements" perdait plusieurs mois d'échéances déjà payées (renumérotées à partir de 1 depuis la date d'effet de la révision), alors que les remboursements réels restaient intacts.
- Cause :
generateSimul()a une logique de "mode restructuration" (sidate_debut_simulposé : conserve les échéances antérieures qui correspondent à un remboursement réel, supprime le reste, renumérote à partir des mois réellement écoulés) — maisgenerateSimulWithReinvestissements()ne l'avait jamais eue : elle supprimait tout l'échéancier et le régénérait en repartant ànumero_echeance = 1. Ce chemin n'avait jamais été exercé avant (aucune fonctionnalité ne posaitdate_debut_simulsur un prêt ayant des réinvestissements). - Fix : dupliqué exactement la logique restructuration de
generateSimul()dansgenerateSimulWithReinvestissements()(backend/src/utils/schedule.js). - Remède pour les investissements déjà impactés : menu ⋮ de "Projections de remboursements" → "Régénérer l'échéancier" (route
/simul/recalculate→adjustSimulForActuals→generateSimulWithReinvestissements, maintenant corrigée).
🔴 Bug trouvé et corrigé — taux_interet = 0 traité comme "absent"
- Cas réel : plateforme de cloud mining à l'arrêt → révision du taux à 0 %. Bloqué côté validation (
z.number().positive()rejette 0) ET aurait été silencieusement ignoré par les gardes!taux_interet(falsy pour0,nulletundefineden JS) qui auraient empêché toute régénération d'échéancier même une fois la validation corrigée. - Fix :
RevisionSchema.nouveau_taux→.nonnegative(). Gardes!inv.taux_interet→inv.taux_interet == nulldansschedule.js(generateSimul,generateSimulWithReinvestissements,adjustSimulForActuals),simul.js(POST /generate), et 3 endroits d'affichage frontend (InvestissementDetail.jsxbouton "Régénérer l'échéancier",SimulRemboursements.jsx,Remboursements.jsx— message "taux manquant"). - Règle à retenir : ne jamais tester un champ numérique métier potentiellement à 0 avec
!champ— toujourschamp == null(ou??pour les fallbacks, déjà en usage ailleurs dans le code pourtaux_interet).
Tooltip "taux implicite" — détection de revalorisation non annoncée
- Colonne "Intérêts" du tableau "Remboursements enregistrés" (
InvestissementDetail.jsx) : tooltip (cell-tooltip, même pattern que la colonne Imposition) calculant le taux annuel implicite brut/net à partir de l'intérêt réellement versé à cette échéance et du capital restant dû juste avant (montant investi + réinvestissements antérieurs − capital déjà remboursé aux échéances précédentes). - But : aider à repérer visuellement une revalorisation de taux non annoncée par la plateforme (le taux implicite change d'une ligne à l'autre sans qu'aucune révision n'ait été saisie).
- Non calculé pour les prêts
differe(un seul versement, pas de série à comparer) — guardé surfreq_interets === 'mensuel' || 'trimestriel'.
Piège outillage — confirmé à nouveau (cf. session 9)
Le mount bash de crowdlending-app était figé sur un instantané ancien (dates de fichiers plusieurs semaines avant la session), sans lien avec les éditions faites via l'outil Edit/Write dans cette session — wc -l/node --check sur le mount bash donnaient un fichier tronqué non représentatif. Solution utilisée : reconstruire le fichier édité dans le dossier outputs (accessible en écriture réelle depuis bash) via lecture complète par l'outil Read, puis node --check / esbuild --loader=jsx dessus pour une vérification syntaxique fiable — en complément (pas remplacement) de la relecture visuelle des zones éditées via Read.