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

22 KiB
Raw Permalink Blame History

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_debutdate_premiere_echeance, date_echeancedate_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

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 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)

// 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) :

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

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 : allRowskpiRows (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.