Compare commits

..

42 Commits

Author SHA1 Message Date
ocroguennec 41c464e6a3 Améliorations diverses 2026-07-20 22:12:49 +02:00
ocroguennec 441da1975a Possibilité d'avoir une clé API Globale par famille 2026-07-20 18:47:58 +02:00
ocroguennec 9eb19efd92 MAj MCP Serveur 2026-07-20 17:30:08 +02:00
ocroguennec 6e58731a20 Fix 2026-07-15 23:08:36 +02:00
ocroguennec 56dd1f89bd Update MCP server 2026-07-15 22:59:44 +02:00
ocroguennec 5a0a1c03ac Correction 2026-07-15 22:46:49 +02:00
ocroguennec fa318f240c MCP Distant 2026-07-15 22:40:52 +02:00
ocroguennec 4d8fb9bab8 API V1 Public + preparation serveur MCP 2026-07-15 19:33:13 +02:00
ocroguennec c843464ccd Création de la feature API V1 2026-07-14 16:22:34 +02:00
ocroguennec f9c5316303 Sauvegarde de la mémoire 2026-07-14 15:40:57 +02:00
ocroguennec 3a9de15526 Fix multi-détenteur 2026-07-14 15:35:35 +02:00
ocroguennec 069e316362 Correction bug importation de données de cashback 2026-07-14 15:25:48 +02:00
ocroguennec 84f1cb19d5 Correction importation 2026-07-14 15:03:08 +02:00
ocroguennec 3e4c0dffa8 Analyse des dossier d'investissemetns incomplets 2026-07-14 14:45:23 +02:00
ocroguennec 4ef7cbee04 Amélioration de l'importation de donnée 2026-07-14 14:10:27 +02:00
ocroguennec 86a047a0c5 Mise en place des objectifs 2026-07-13 17:53:22 +02:00
ocroguennec 63f38b5ff0 Amélioration XIRR 2026-07-13 07:28:53 +02:00
ocroguennec 7189507998 XIRR Label 2026-07-13 07:16:46 +02:00
ocroguennec 7ace2c8f2e Amélioration du calcul XIRR 2026-07-13 07:11:42 +02:00
ocroguennec 316a86c95a Fix 2026-07-12 19:38:41 +02:00
ocroguennec 47c5ad34fa Fixe bug 2026-07-12 19:29:52 +02:00
ocroguennec 96effbe15a Nouvelles features: revision des conditions des prêts 2026-07-12 19:14:27 +02:00
ocroguennec ef4576d160 Amélioration de la détection 2026-07-12 18:11:51 +02:00
ocroguennec 794f8684a2 FixBug: Décalrage de date des prêts différés 2026-07-12 17:58:28 +02:00
ocroguennec ec3c7f7cab Corrections sur les jobs En retard 2026-07-12 17:39:57 +02:00
ocroguennec 0d8f92bc9a Bug de rafraichissement de données 2026-07-05 10:50:17 +02:00
ocroguennec 186ffc2596 Selection de la plateforma avant import 2026-07-05 10:40:33 +02:00
ocroguennec 1f75878d87 Correctif Contrainte de suppression de compte 2026-07-05 00:54:40 +02:00
ocroguennec 5e775da711 Amélioration de l'import 2026-07-05 00:37:38 +02:00
ocroguennec 8bcd7e603d Modification de l'import avec prompt IA 2026-07-04 23:09:46 +02:00
ocroguennec 8834236840 Evite les doublons 2026-07-04 19:17:56 +02:00
ocroguennec b138bc53ee Améliorationde l'import 2026-07-04 18:55:40 +02:00
ocroguennec 11790c63fe Correction de l'affichage en tant que mono détenteur 2026-07-04 18:32:51 +02:00
ocroguennec 69d006d358 Correctif redescente du référentiel 2026-07-04 17:43:57 +02:00
ocroguennec ff77ee977d Amélioration pour nouveau connecté 2026-07-03 20:44:32 +02:00
ocroguennec 53742568ce UX amélioration 2026-07-03 20:01:43 +02:00
ocroguennec 2281894802 Feature: Suppression de son compte 2026-07-03 19:49:32 +02:00
ocroguennec 89190c4561 Améliroation de la piste d'audit 2026-07-03 19:49:14 +02:00
ocroguennec e11c767c1c UX améliorations 2026-07-03 19:02:44 +02:00
ocroguennec b481c4cc1d MAJ 2026-07-03 18:33:58 +02:00
ocroguennec 563063ba17 Ajout de la possibilité de voir le mot de passe 2026-07-03 18:31:59 +02:00
ocroguennec 3c5aa635c4 Ajout direct pour nouveau user 2026-07-03 18:21:24 +02:00
94 changed files with 12741 additions and 519 deletions
+295 -1
View File
@@ -1,5 +1,5 @@
# MEMORY.md — Crowdlending Tracker
*Dernière mise à jour: 2026-06-15 (session 7)*
*Dernière mise à jour: 2026-07-14 (session 15)*
---
@@ -151,6 +151,7 @@ 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/DELETE /api/objectifs?type=&annee=
GET/POST/PUT/DELETE /api/categories
POST /api/imports/preview | apply
GET /api/imports/history
@@ -425,3 +426,296 @@ const isBonus = BONUS_VALUES.includes(form.investissement_id);
- `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` : prop `to` par défaut passée de `/settings?section=plateformes` à `/settings?section=plateformes&openAdd=1`
- `PlateformesSection.jsx` : `useSearchParams` + `useEffect` détecte `openAdd=1` au montage → ouvre directement `showAddPicker` (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é + `useEffect` de 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) togglant `type` entre `password`/`text`
- API : toutes les props (`className`, `value`, `onChange`, `required`, `autoComplete`, `minLength`, `style`, etc.) sont transmises telles quelles à l'`<input>` interne ; `wrapperStyle` optionnel 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 }`) dans `auth.js` : vérifie le mot de passe (bcrypt), bloque si l'utilisateur est le **dernier admin** (`COUNT(*) WHERE role='admin' <= 1`), logge un audit `account_self_deleted` (catégorie `account`, `details.initiated_by:'self'`) **avant** la suppression, notifie tous les autres admins (type `security`, lien `/admin?section=audit-logs`), puis `DELETE 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 CASCADE` déjà en place sur `user_id`/`investisseur_id`/etc. (`db.pragma('foreign_keys = ON')` activé globalement dans `db/index.js`). C'est le même mécanisme que `DELETE /api/admin/users/:id` (admin.js) qui fait déjà un simple `DELETE FROM users` sans étape de nettoyage manuel
- `audit_logs.actor_id`/`target_user_id` sont en `ON DELETE SET NULL` (pas CASCADE) : le log survit à la suppression de l'utilisateur, les infos identifiantes (email, display_name, role) sont dupliquées dans `details` JSON pour rester lisibles même une fois les FK à NULL
- Frontend : `AuthContext.deleteAccount(password)``api.del('/auth/me', {password})` puis `logout()` ; `api.del` accepte maintenant un `body` optionnel (`api.js`)
- `DeleteAccountSection` dans `MonCompte.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.jsx` affiche 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 **sans `is_principal=1`** et **sans compte courant**
- `POST /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 mais `is_principal=0` et zéro compte
- **Fix appliqué** :
1. `admin.js` (`POST /users`) et `invitations.js` (`POST /:token/register`) répliquent maintenant exactement la logique de `auth.js` : `INSERT INTO investisseurs (..., is_principal) VALUES (..., 1)` + `INSERT INTO comptes (user_id, nom, type, investisseur_id)` avec `nom = 'Compte courant — ' + fullName`
2. Backfill idempotent ajouté en fin de `backend/src/db/index.js` (avant `export 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 investisseur `famille` pour 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 utilisent `NOT 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` (`investisseurs` avec `is_principal=1` + `comptes` type `compte_courant`) — ne pas dupliquer seulement l'insert `investisseurs`.
---
## 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;` (ou `investisseurs.length > 1` si 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 + `colSpan` ajusté + tfoot spacer
- **Idem sur les exports** (XLS/CSV/JSON) : passer `multiDetenteur` en 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()` ouvre `corrModal` (vérification du solde déclaré) au lieu d'appeler `load()` 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 via `normalizeName`) pour `plateforme_id` (modules `depots_retraits`/`investissements`) et `investissement_id` (module `remboursements`)
- 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_souscription
- `remboursements` : investissement + date_remb + capital + interets_bruts (tolérance 0,005)
- `plateformes` : déjà couvert par `UNIQUE(nom)` → reclassé en doublon plutôt qu'erreur
- Compteur `duplicates` distinct de `skipped` (vraies erreurs), colonne DB `imports.rows_duplicates`, affiché dans l'historique
### Import — anomalie date antérieure à la date d'ouverture de la plateforme
- Pour `depots_retraits`/`investissements` uniquement (pas `remboursements`, 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 `/apply` inclut `anomalies: [{ 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 le `PUT /:id` complet)
- `ImportsSection.jsx` affiche 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 fence ```json```) qui est réinjecté dans le pipeline preview/apply existant via un `Blob`/`File` virtuel (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 avec `SqliteError: FOREIGN KEY constraint failed` (code `SQLITE_CONSTRAINT_TRIGGER`) à `auth.js:319`.
- **Cause** : `depots_retraits.plateforme_id` et `investissements.plateforme_id` étaient en `ON DELETE RESTRICT` (seuls FK RESTRICT du schéma) — lors du `DELETE FROM users`, les branches de cascade `users→plateformes` et `users→investisseurs→depots_retraits/investissements` sont indépendantes ; SQLite peut supprimer la plateforme avant les lignes qui la référencent encore, déclenchant RESTRICT.
- **Fix** : migration `fixPlateformeCascade` dans `db/index.js` (recréation table via `__repair_*`, RESTRICT→CASCADE) + `schema.sql` mis à jour + protection déplacée côté application dans `DELETE /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`/`users` doit être en `CASCADE` (ou `SET NULL`), jamais `RESTRICT` — 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 via `mcp__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. reconstruction `python3` après troncature confirmée par `Read`).
---
## 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 `differe` avec `date_cible` aberrante (années 2100 à 2650), sans aucune trace dans `investissement_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_cible` pour les prêts différés, censée maintenir l'égalité des deux dates), cela formait une **boucle infinie** : à chaque redémarrage, `date_cible` dé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 `migrationEnCours` est vrai (colonnes `date_debut`/`date_echeance` encore présentes, càd le jour réel du renommage) — plus jamais au démarrage normal. La migration `date_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`) dans `db/index.js` doit être strictement idempotente/one-shot — soit via clause `WHERE champ IS NULL`, soit gardée derrière la détection de l'événement historique qui la justifie. Ne jamais laisser un `UPDATE` sans 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) écrasait `date_cible`/`date_premiere_echeance`/`montant_investi`/`taux_interet`/`duree_mois`/`type_remb`/`statut` avec seulement un historique générique ("Mise à jour dossier"), sans le détail des champs modifiés. Fix : réutilise `detectChangements`/`recordHistory`/`detectTypeEvenement` (désormais **exportés** depuis `investissements.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 les `date_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`)
1. **"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_remboursements` via `generateSimul()` 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).
2. **"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 que `simul_remboursements` contient exactement 1 échéance à la date `date_premiere_echeance`/`date_cible` du prêt ; régénère sinon (`generateSimulWithReinvestissements` si réinvestissements présents, sinon `generateSimul`), log `correction_auto_echeancier`.
### Job `autoStatut.js` — statuts automatiques enrichis
- `checkStatutsRetard()` fait maintenant **les deux sens** : `en_cours→en_retard` (comme avant, log `passage_auto_retard`) ET `en_retard→en_cours` (nouveau, log `retour_auto_en_cours`) — mais uniquement si (a) `date_cible` est 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 bien `passage_auto_retard` (jamais d'annulation automatique d'un passage en retard décidé manuellement).
- Chaque transition génère une **notification utilisateur** (table `notifications`) : type `warning` pour le passage en retard, type `success` pour le retour en cours, avec lien direct `/investissements/:id`.
- `TRACKED_FIELDS`, `recordHistory`, `detectChangements`, `detectTypeEvenement` sont maintenant `export` depuis `investissements.js` (réutilisés par `imports.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.db` est en mode WAL et activement écrit par le serveur Node de l'utilisateur pendant la session. Une copie manuelle (`cp` sé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-sqlite3` du repo est un binaire natif **Windows** (`node_modules/better-sqlite3/build/Release/better_sqlite3.node`) → `invalid ELF header` dans le sandbox Linux. Utiliser `python3` + module `sqlite3` standard à 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 dans `db/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 /:id` retourne `revisions` au même niveau que `historique`/`simul`.
- **Point technique clé** : `generateSimul()` se base sur `duree_mois`, **pas** sur `date_cible` (champ d'affichage/contractuel). Donc si `nouvelle_date_cible` est fournie, `duree_mois` est recalculé (`monthsDiff(date_premiere_echeance, nouvelle_date_cible) + 1`) sinon la nouvelle date cible ne serait que cosmétique. Ancien/nouveau `duree_mois` tracés en base pour un rollback fidèle.
- Helper `regenererEcheancier(investissementId)` dans `investissements.js` : appelle `generateSimulWithReinvestissements` si l'investissement a des réinvestissements, sinon `generateSimul`.
- 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" (si `date_debut_simul` posé : conserve les échéances antérieures qui correspondent à un remboursement réel, supprime le reste, renumérote à partir des mois réellement écoulés) — mais `generateSimulWithReinvestissements()` 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 posait `date_debut_simul` sur un prêt ayant des réinvestissements).
- **Fix** : dupliqué exactement la logique restructuration de `generateSimul()` dans `generateSimulWithReinvestissements()` (`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 pour `0`, `null` et `undefined` en 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 == null` dans `schedule.js` (`generateSimul`, `generateSimulWithReinvestissements`, `adjustSimulForActuals`), `simul.js` (`POST /generate`), et 3 endroits d'affichage frontend (`InvestissementDetail.jsx` bouton "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` — toujours `champ == null` (ou `??` pour les fallbacks, déjà en usage ailleurs dans le code pour `taux_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é sur `freq_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`.
---
## Session 12 — KPI XIRR sur la page Plateformes (2026-07-13)
### Rendement annualisé (XIRR) estimé pour les prêts en cours + renommage KPI
Sur `InvestissementDetail.jsx`, le KPI "Rendement annualisé" a été renommé **"XIRR — Brut/Net"** et calculé désormais pour **tous les statuts** (plus seulement `rembourse`) : pour un prêt non soldé, on ajoute un flux de trésorerie synthétique final = capital restant dû, daté du jour ("valorisation à date"), en plus des flux réels (versement initial, réinvestissements, remboursements). Le label affiche "(estimé)" tant que le prêt n'est pas intégralement remboursé. Un popup (icône info + `cell-tooltip tooltip-down`) explique la méthodologie. Le modificateur CSS `.tooltip-down` (`top: calc(100% + 6px)`) a été ajouté car le tooltip par défaut s'ouvre vers le haut et était coupé en haut de viewport pour ce KPI proche du sommet de page.
### Fonction `xirr()` extraite en utilitaire partagé
Déplacée de `InvestissementDetail.jsx` (définition locale) vers `frontend/src/utils/xirr.js` (export nommé `xirr`), pour réutilisation sans duplication. Newton-Raphson sur flux datés, retourne `null` si non convergent ou < 2 flux.
### Nouveau KPI XIRR sur la page Plateformes
Ajouté en 6e position dans `dr-kpi-row` de `Plateformes.jsx` (grid passée de `repeat(5,1fr)` à `repeat(6,1fr)`), juste après "Intérêts perçus" — même format que la fiche investissement (label + icône info + tooltip explicatif, "(estimé)" si applicable).
- Calcul agrégé (`plateformeXirr`, `useMemo`) sur `chartRows` (déjà filtré plateforme + détenteur + année) : flux datés individuels (pas des sommes) — investissement initial (négatif) + réinvestissements (négatif) + remboursements réels (positif, brut = capital+cashback+interets_bruts, net = `net_recu`), tous filtrés par la même coupure `cutoff` que les autres agrégats de la page (`${selectedYear}-12-31` si une année est sélectionnée, sinon `today()`).
- Valorisation à date : pour chaque investissement de `chartRows` encore actif (capital restant dû > 0 à la coupure), un flux positif = capital restant est ajouté à la date de coupure — cohérent avec la logique déjà utilisée pour `totals.encours`.
- Flux triés chronologiquement avant l'appel à `xirr()` (invariant mathématiquement par rapport à l'ancre `t0`, mais plus robuste/lisible).
- Choix assumé : pas de `TrendBadge` (comparaison N-1) sur ce KPI — contrairement aux 5 autres — car un XIRR n'est pas additif d'une année sur l'autre comme un total cumulé ; à la place, un sous-texte indique la date de valorisation ("Valorisé au 31/12/2025" ou "Valorisé au aujourd'hui").
### Piège outillage — reconfirmé une 3e fois (sessions 9, 11, 12)
Le mount bash reste figé sur un instantané qui ne reflète pas les éditions de la session (`wc -l` sur le mount stagnait à 1591 lignes alors que le fichier réel via l'outil `Read` en comptait 1675, et ce même après un nouveau `cp` explicite). Vérification faite intégralement via relecture complète du fichier par l'outil `Read` (comptage de lignes, relecture des zones éditées, vérification de fermeture des blocs). Ne plus perdre de temps à essayer de resynchroniser le mount bash pour de la vérification syntaxique sur ce projet — se fier à une relecture `Read` complète et méthodique en priorité.
---
## Session 13 — Objectifs annuels de versement (2026-07-13)
### Nouvelle fonctionnalité : suivi d'objectifs de versement annuel
Demande initiale : pouvoir fixer un objectif de dépôts nets par an et suivre l'écart, en partant de la page Plateformes (onglet Dépôts/Retraits), avec une architecture réutilisable ailleurs.
**Décisions produit actées après clarification (questions posées avant implémentation, conformément à la règle "poser des questions avant tâche complexe")** :
- Objectif défini **par investisseur et par année** (pas global, pas par plateforme) — en vue "tous les investisseurs", affichage = somme des objectifs individuels.
- Le suivi est **toujours calculé sur le portefeuille entier** (toutes plateformes), indépendamment du filtre plateforme actif sur la page qui l'affiche.
### Modèle de données
Nouvelle table `objectifs` (migration dans `db/index.js`, avant `export default db`) :
```sql
CREATE TABLE objectifs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
investisseur_id INTEGER NOT NULL REFERENCES investisseurs(id) ON DELETE CASCADE,
type TEXT NOT NULL DEFAULT 'versement_annuel',
annee INTEGER NOT NULL,
montant REAL NOT NULL,
notes TEXT,
created_at/updated_at TEXT,
UNIQUE(investisseur_id, type, annee)
)
```
Le champ `type` est prévu pour réutiliser la table plus tard (ex. `'rendement_annuel'`) sans nouvelle migration — seul `'versement_annuel'` est utilisé actuellement.
### Backend
`backend/src/routes/objectifs.js` (monté sur `/api/objectifs` dans `server.js`, entre `garantiesRouter` et `reinvestissementsRouter`) : `GET /` (filtres `?type=&annee=`, scope implicite = tous les investisseurs de `req.user.id` via jointure), `POST /` (upsert `ON CONFLICT(investisseur_id, type, annee)`), `DELETE /:id`. Pattern calqué sur `notation.js` (pas de middleware `requireInvestisseur`, ownership vérifiée par jointure SQL).
### Composant frontend réutilisable
`frontend/src/components/SuiviObjectifs.jsx` — props : `rows` (mouvements bruts dépôts/retraits), `investisseurs`, `scopeInvestisseurIds` (tous les ids en vue "all", sinon `[activeId]`), `objectifType` (défaut `'versement_annuel'`), `title`. Gère lui-même le fetch/upsert/delete des objectifs. Tableau année par année : Année | Dépôts | Retraits | Différence | **Objectif annuel** | Écart, + **ligne Total** en `<tfoot>` (somme des colonnes sur les années affichées ; Objectif/Écart totaux affichent "—" si aucune année n'a d'objectif défini). Édition : clic sur la cellule Objectif annuel → un input par investisseur du scope (un seul si scope=1), Enregistrer fait un `Promise.all` de POST upsert (et DELETE si champ vidé). Bouton "+ Ajouter une année" pour anticiper une année future sans mouvement.
### Intégration (2 emplacements)
1. `Plateformes.jsx` — onglet Dépôts/Retraits, section ajoutée sous le tableau "Mouvements de trésorerie" (`rows={allDepots}`).
2. `DepotsRetraits.jsx` (page dédiée du menu latéral) — **onglet dédié "Vision annuelle"** dans `.dr-tabs`, positionné entre "Plateformes" et "Vision mensuelle" (`activeTab === 'vision-annuelle'`, `rows={allRows}`). Le KPI existant "Diff. Dépôts vs Retraits" est enrichi d'une ligne sous le chiffre : "Reste X € pour l'objectif AAAA" ou "+X € au-delà de l'objectif AAAA" (calcul indépendant du filtre plateforme, année = `drPlatYear` ou année en cours ; nécessite un fetch local `objectifsKpi` séparé de celui du composant, car le composant gère son propre state).
### Piège outillage — vérification syntaxique de fichiers JSX volumineux
Le mount bash reste périmé (cf. sessions précédentes). Pour vérifier un **nouveau** composant JSX isolé (pas une édition dans un fichier existant de 1000+ lignes), la méthode fiable trouvée cette session : `npm init -y && npm install esbuild` dans `/tmp` (indépendant du node_modules Windows du projet, incompatible avec le sandbox Linux), copier le contenu exact du fichier via `cat > fichier << 'EOF'` puis `esbuild fichier.jsx --bundle --format=esm --jsx=automatic --external:react --outfile=...`. Pour une **édition ponctuelle** dans un gros fichier existant, la relecture `Read` ciblée des zones modifiées (comptage d'accolades/balises JSX) reste suffisante et plus rapide.
---
## Session 14 — Import IA (prompt + bugs), modale doublons, purge plateforme, job données incomplètes (2026-07-14)
### Prompt IA import Remboursements — itérations successives (`ImportsSection.jsx`, `buildReferenceSection`/`FIELD_HINTS_OVERRIDE`)
1. **Plateforme unique** : si une seule plateforme existe, le prompt l'assigne directement à toutes les lignes sans poser de question (branche `plats.length === 1` dans `buildReferenceSection`).
2. **Règle CAPITAL vs INTÉRÊTS** : distingue une ligne "remboursement mensualité" unique (montant mêlant capital+intérêts) via le ratio (prélèvements sociaux + IR) ÷ montant total : proche de 30 % → intérêts purs ; nettement inférieur → reconstitue `interets_bruts ≈ moyenne(prelev_sociaux/0.172, prelev_forfaitaire/0.128)` puis `capital = total interets_bruts` ; aucun prélèvement adjacent → capital pur (échéance finale in fine/différé).
3. **Identification cashback/bonus** (ajoutée suite à un cas réel : "Rémunération code cadeau") : reconnue par le **libellé** (mots-clés "cashback", "bonus", "prime", "code cadeau", "parrainage"...), jamais par l'absence de prélèvement seule (un remboursement de capital pur n'a lui non plus aucun prélèvement adjacent — ne pas confondre). Si le libellé ne référence aucun projet suivi (ex. parrainage global) → ligne exclue du JSON (le module d'import exige un `investissement_id`).
4. **Règle critique présence systématique des champs** : imposer que les 5 champs (`capital`, `cashback`, `interets_bruts`, `prelev_sociaux`, `prelev_forfaitaire`) soient toujours explicitement présents sur CHAQUE ligne (valeur 0 si non applicable), jamais omis — corrige un vrai bug de détection (voir ci-dessous).
5. Suppression de la consigne "génère aussi `net_recu`" (champ jamais lu par le backend, recalculé côté serveur depuis capital/cashback/intérêts/prélèvements) — source de confusion sans utilité.
### 🔴 Bug trouvé et corrigé — détection des colonnes basée uniquement sur la 1ère ligne du JSON
- **Symptôme réel** : un remboursement de capital final (250 €) importé à 0,00 € partout dans l'app, alors que le JSON source contenait bien `"capital": 250`.
- **Cause** : `POST /imports/preview` calcule `headers = Object.keys(rows[0])` — seule la première ligne du fichier sert à détecter les colonnes disponibles pour l'auto-mapping. Si un champ (ex. `capital`) est absent de la première ligne (fréquent avec les JSON générés par IA où seules certaines lignes ont telle ou telle info) mais présent plus loin, il n'est **jamais mappé** pour tout le fichier — donc toujours lu comme `0`, silencieusement, même sur les lignes où il est renseigné.
- **Fix** : uniquement via le prompt (règle 4 ci-dessus, pas de changement de code) — imposer à l'IA génératrice de toujours inclure tous les champs optionnels sur chaque ligne, à 0 par défaut.
### 🔴 Bug trouvé et corrigé — champ `cashback` absent du schéma d'import Remboursements
- **Symptôme réel** : une ligne de cashback (`"cashback": 2.5`) importée avec `cashback: 0,00 €` alors que tous les autres champs de mapping fonctionnaient.
- **Cause, différente du bug précédent** : `MODULES.remboursements.optional` (`ImportsSection.jsx`) ne listait jamais `cashback` parmi les champs du module — ni pour l'auto-mapping (`runPreview`), ni pour le mapping manuel (`<select>`), ni pour la liste de champs générée dans le prompt IA (`buildFieldsList`). Le champ existe pourtant en base et dans le formulaire manuel de saisie. Bug préexistant, pas introduit cette session.
- **Fix** : ajout de `'cashback'` à `MODULES.remboursements.optional`.
- **Point d'attention** : les lignes déjà importées avec cashback perdu (0 au lieu du vrai montant) ne sont pas corrigées rétroactivement — la détection de doublon remboursements ne compare pas le cashback, donc un ré-import est soit ignoré comme doublon, soit crée une ligne en double si on force l'acceptation. Correction manuelle ligne par ligne recommandée dans ce cas.
### 🔴 Bug trouvé et corrigé — l'import de remboursements ne reproduit pas la logique de la saisie manuelle
- **Symptôme réel** (2 captures d'écran comparées, dev vs prod) : un investissement soldé (capital intégralement remboursé) via **import** restait affiché `en_cours` avec un échéancier complet non réajusté (22 échéances futures inchangées), alors que le même remboursement saisi **manuellement** passait bien l'investissement à `rembourse` et tronquait l'échéancier aux échéances réellement dues (5 au lieu de 22).
- **Cause** : `POST /remboursements` (saisie manuelle, `routes/remboursements.js`) appelle après chaque insertion `syncInvestissementStatut()` (passe `rembourse` si capital total remboursé ≥ montant investi + réinvestissements) et `adjustSimulForActuals()` (tronque/recalcule les échéances futures devenues caduques en cas de remboursement anticipé). `POST /imports/apply` (module `remboursements`) ne faisait qu'un `INSERT` brut en boucle, sans jamais appeler ces deux fonctions.
- **Fix** : `syncInvestissementStatut` exportée depuis `remboursements.js` ; `imports.js` importe cette fonction + `adjustSimulForActuals` (déjà exportée de `schedule.js`) ; après le commit de la transaction d'import (module `remboursements`), boucle best-effort sur tous les `investissement_id` touchés par l'import pour appeler les deux fonctions — reproduit exactement le comportement de la saisie manuelle.
- **Règle à retenir** : toute route d'import en masse qui insère directement dans une table déjà pourvue d'effets de bord post-insertion (recalcul de statut, régénération d'échéancier...) côté route manuelle doit rejouer ces mêmes effets de bord après le commit — ne pas se contenter du simple `INSERT`.
### Modale de revue des doublons à l'import (tous modules)
- Nouvelle route `POST /imports/check-duplicates` (dry-run, même détection que `/apply` mais sans écriture) + `duplicateDecisions` accepté par `/apply` (`{ [rowNum]: 'accept'|'skip' }`).
- Détection des doublons **internes au fichier** (pas seulement contre la base) via des `Map` en mémoire (`seenDepotsRetraits`, `seenInvestissements`, etc.) qui simulent l'effet séquentiel d'une transaction SQLite réelle (une ligne répétée plus loin dans le même fichier est comparée aux lignes déjà "vues", pas seulement à la base).
- UI (`ImportsSection.jsx`) : ligne = case à cocher + titre "Doublon de données repéré en ligne X avec celles de la ligne Y (ou un enregistrement déjà en base)", détail replié par défaut (chevron), boutons "Cocher/Décocher tous les doublons".
- **Pièges CSS rencontrés** (`styles.css` a des sélecteurs globaux `label`/`input` qui fuient sur du HTML brut) : `label { text-transform: uppercase; ...}` et `input,select,textarea { width:100%; padding:7px 10px; ...}` s'appliquent même à une checkbox de ligne — toujours réinitialiser explicitement en inline style (`textTransform:'none'`, `width:14, height:14, padding:0, flexShrink:0`), pattern déjà présent ailleurs (`.cat-select-item input[type="checkbox"]`). `.modal-overlay`/`.modal`/`.modal-header` étaient absentes de `styles.css` (seule `.modal-backdrop` existait, pour le composant `Modal.jsx` partagé) — ajoutées, corrige aussi rétroactivement les modales ad-hoc de `DataCleanupSection.jsx`.
- Après import réussi : le textarea "Analyser les données" (IA) se vide et la page recharge automatiquement (message de résultat conservé via `sessionStorage` le temps du reload, réaffiché au montage).
### Job horaire — données essentielles manquantes sur les investissements
- Nouveau `backend/src/jobs/checkDonneesIncompletes.js`, démarré dans `server.js` (`startCheckDonneesIncompletesJob`, pattern horaire identique à `autoTicketStatus.js`).
- Vérifie sur chaque investissement `statut != 'cloture'` : `taux_interet`, `duree_mois`, `type_remb`, `date_premiere_echeance`.
- **Anti-doublon de notification** : nouvelle colonne `investissements.donnees_incompletes_signature` (migration) stockant la liste triée des champs actuellement manquants. Notification renvoyée seulement si cette signature change (nouveau manque, ou toujours incomplet mais différemment) ; réinitialisée silencieusement (sans notif) quand tout redevient complet.
- Même fonction rejouée en best-effort à la fin d'un import `investissements` réussi (`imports.js`, après le commit de la transaction).
- Scope volontairement limité à ces 4 champs (portée assumée sans élargir sur le "etc." de la demande initiale).
### Suppression de données d'une plateforme (`DataCleanupSection.jsx` + `POST /plateformes/:id/purge-donnees`)
- Scopes : toutes les données / dépôts-retraits / investissements (+ remboursements liés en cascade) / remboursements uniquement. La fiche plateforme n'est jamais supprimée.
- **Confirmation par PIN à 6 chiffres** (remplace le "retapez le nom de la plateforme") : PIN aléatoire généré à l'ouverture de la modale (`Math.floor(100000 + Math.random()*900000)`), affiché en gros (monospace 30px, espacé, rouge) ; le payload envoyé à l'API contient toujours `confirmNom: purgePlat.nom` en interne (le PIN est une couche de confirmation UI uniquement, la vérification serveur par nom exact est inchangée).
- **Sélecteur de plateforme corrigé pour le multi-détenteur** : deux plateformes de familles différentes peuvent porter le même nom (ex. deux comptes "Enky"). Fix en reprenant le pattern déjà établi ailleurs (`const multiDetenteurPlats = new Set(plats.map(p => p.investisseur_id)).size > 1`, suffixe `— {investisseur_nom}` dans les `<option>` et rappel dans le texte de la modale, uniquement si multi-détenteur) — cf. session 9 "Colonne Détenteur — masquée si mono-détenteur", même pattern à répliquer sur tout futur select de plateformes.
---
## Session 15 — Roadmap API publique + serveur MCP, phases 0 à 2 (2026-07-14)
Objectif produit (discuté et validé avant implémentation, cf. règle "poser des questions avant tâche complexe") : ouvrir une API en lecture seule + un serveur MCP local pour piloter l'app via un agent IA (Claude Desktop). Roadmap en 5 phases actée : 0) clés API, 1) API publique v1 + doc, 2) serveur MCP local (stdio), 3) outil `fetch_url` (non fait), 4) déploiement Docker prod, 5) MCP distant (non fait, plus tard). **Phases 0, 1, 2 et le déploiement en prod (phase 4) sont faites et validées par l'utilisateur en production.**
### Phase 0 — Clés API
- Nouvelle table `api_keys` (migration `db/index.js`) : `user_id`, `investisseur_id` (une clé = un seul investisseur, jamais tous), `nom`, `key_prefix` (12 premiers car. affichés dans l'UI), `key_hash` (SHA-256, jamais le clair stocké), `scopes` (`'read'` seul utilisé), `created_at`, `last_used_at`, `revoked_at`.
- Format de clé : `clk_live_` + 24 octets hex aléatoires. Générée par `backend/src/routes/apiKeys.js` (monté `/api/api-keys`, protégé par le JWT interne classique — c'est l'utilisateur qui gère ses clés via l'app, pas la clé elle-même qui authentifie ces routes).
- Deux suppressions distinctes, bien différenciées côté UI (`MonCompte.jsx`, section "Clés API") :
- **Révocation** (`DELETE /api-keys/:id`) : soft-delete, `revoked_at` set, la ligne reste visible pour historique/`last_used_at`.
- **Suppression définitive** (`DELETE /api-keys/:id/purge`) : hard-delete. Icône poubelle par ligne — sur une clé **active**, ouvre une modale d'avertissement (irréversible, coupe l'accès immédiatement, suggère plutôt "Révoquer") ; sur une clé **déjà révoquée**, suppression directe sans confirmation (elle ne sert déjà plus à rien).
- **Piège sécurité observé en session** : l'utilisateur a collé une clé API en clair dans le chat pour "tester". Réflexe correct appliqué : recommander révocation immédiate + regénération, rappeler qu'une clé ne doit transiter que entre l'app et le fichier de config local, jamais par un canal de conversation.
### Phase 1 — API publique `/api/v1` (lecture seule) + Swagger
- Middleware `backend/src/middleware/apiKey.js` (`requireApiKey`) : lit `X-API-Key`, hash SHA-256, vérifie non révoquée, met à jour `last_used_at`, injecte `req.investisseurId`/`req.apiKeyId`/`req.apiScopes`. Distinct de `requireAuth` (JWT).
- Routes dans `backend/src/routes/v1/` (agrégées par `v1/index.js`) : `GET /investisseur`, `GET /investissements` (+ filtre `?statut=`), `GET /investissements/:id` (avec ses remboursements), `GET /remboursements` (+ `?date_debut=&date_fin=`), `GET /depots-retraits`, `GET /dashboard` (KPIs simplifiés, requêtes SQL propres à v1, pas de réutilisation de la logique complexe de `routes/dashboard.js`). Toutes scopées strictement à `req.investisseurId` — pas de notion `scope=all` ici (une clé = un investisseur).
- **Piège routing critique (corrigé)** : `server.js` a une route générique `app.use('/api', requireAuth, associationsInvRouter)` qui capte tout préfixe `/api/*`. Le montage de `/api/v1` et `/api/docs` doit impérativement se faire **avant** cette ligne (juste après `/api/auth`), sinon toute requête vers `/api/v1/*` est interceptée par le JWT interne et renvoie 401 "Missing or invalid Authorization header" au lieu d'atteindre `requireApiKey`.
- Doc Swagger : `backend/src/swagger.js` (`swagger-jsdoc` + `swagger-ui-express`), annotations `@openapi` dans chaque route `v1/*.js`, servie sur `/api/docs` (public) + `/api/openapi.json`. Dépendances ajoutées à `backend/package.json`.
- **Piège Windows** : `swagger-jsdoc` résout son option `apis` (glob) via une lib qui n'interprète pas les antislashs — `path.join(__dirname, 'routes/v1/*.js')` sous Windows produit des `\`, donc 0 route détectée ("No operations defined in spec!"). Fix : forcer des `/` (`p.split(path.sep).join('/')`) avant de passer le pattern à `swagger-jsdoc`.
- **Piège annotations** : le `server.url` OpenAPI est déjà `/api/v1` — les chemins `@openapi` doivent être relatifs (`/dashboard`, pas `/v1/dashboard`), sinon Swagger UI construit des URLs doublées (`/api/v1/v1/dashboard`) qui retombent sur la route catch-all JWT (401 trompeur, à ne pas confondre avec un vrai problème de clé).
- Testé en prod par l'utilisateur : création clé → doc Swagger (Authorize + Try it out) → 200 avec vraies données → révocation → 401 `"Invalid or revoked API key"`. Confirmé aussi compatible Power Query Excel (`Web.Contents` + header `X-API-Key`, pas de souci CORS car requête serveur-à-serveur).
### Phase 2 — Serveur MCP local (`mcp-server/`, nouveau dossier à la racine)
- Package Node **séparé** du monorepo (son propre `package.json`), pur JS sans dépendance native (`@modelcontextprotocol/sdk` + `zod`) — contrairement au backend (`better-sqlite3`), donc **installable et testable tel quel dans le sandbox Linux**, aucun problème cross-plateforme.
- `index.js` : `McpServer` + `StdioServerTransport`, 6 outils lecture seule préfixés `crowdlending_` (`get_investisseur`, `get_dashboard`, `list_investissements`, `get_investissement`, `list_remboursements`, `list_depots_retraits`), chacun appelle l'API v1 via `fetch` natif (Node ≥ 18) avec le header `X-API-Key`. Auth par `CROWDLENDING_API_KEY` (obligatoire, `process.exit(1)` sinon) + `CROWDLENDING_API_URL` (défaut `http://localhost:4000/api/v1`).
- **Règle stdio impérative** : ne jamais `console.log` dans ce process (stdout = canal protocole JSON-RPC) — uniquement `console.error` pour les diagnostics. Vérifié en session (stdout capturé vide, stderr contient les logs).
- **Distinction dev/prod pour connexions simultanées** : `CROWDLENDING_LABEL` (env var facultative) préfixe le nom du serveur MCP et ajoute `[DEV]`/`[PROD]` au titre + la source (URL API) en fin de description de chaque outil. Pas de collision technique possible entre deux instances connectées en même temps à Claude Desktop même si les noms d'outils sont identiques : le client namespace déjà par clé du bloc `mcpServers` (comme observé dans ce contexte agent : `mcp__<serveur>__<outil>`). Le `LABEL` sert uniquement à la lisibilité humaine/agent.
- UI `MonCompte.jsx`, section "Serveur MCP" (nav `id: 'mcp'`) : guide pas-à-pas (créer une clé dédiée → renseigner chemin/URL/clé → JSON généré à copier → redémarrer Claude Desktop → vérifier). Le JSON est composé **côté client uniquement** — la clé saisie dans le champ n'est jamais envoyée au backend, juste utilisée pour l'aperçu affiché.
- **Détection automatique dev/prod par URL** (`detectLabelFromUrl`) : hostname `localhost`/`127.0.0.1`/`.local` ou contenant "dev" → `DEV`, sinon `PROD` ; badge coloré affiché, case à cocher "Forcer manuellement" pour les cas ambigus (ex. domaine de test sans "dev" dans le nom). `guessMcpApiUrl()` distingue déjà dev (`:5173` → viser directement le backend `:4000`, le process Node MCP ne passe pas par le proxy Vite) vs prod (même origine que le frontend, nginx proxy `/api`).
- `README.md` dédié dans `mcp-server/` : install, config Claude Desktop, double config dev+prod, test via MCP Inspector, tableau des 6 outils, dépannage.
### Piège outillage — cache bash figé sur un fichier précis
Le mount bash (lecture de `C:\dev\crowdlending-app` depuis le sandbox Linux) a servi une version **figée à la toute première écriture** de `mcp-server/index.js` (même taille en octets, même `mtime`) malgré plusieurs `Edit` puis un `Write` complet ultérieurs — contrairement au comportement habituel de simple lag résolu par un `sleep`. Confirmé via `stat` (mtime figé). Contournement qui a fonctionné : écrire un fichier de contenu équivalent **directement via bash** (heredoc, sans passer par le mount Windows) dans `/tmp`, et valider la syntaxe/le comportement dessus — le `Read` tool (accès direct au filesystem Windows) reste la seule source fiable pour le contenu réel du fichier concerné.
### Reste à faire (roadmap)
- Phase 3 : outil MCP `fetch_url` (extraction structurée d'une page plateforme pour pré-remplir un investissement, sans écriture automatique).
- Phase 5 : variante MCP distante (HTTP/SSE, OAuth) dans le `docker-compose.yml` de prod — plus tard, une fois l'usage local stabilisé.
+477
View File
@@ -22,10 +22,60 @@
"otplib": "^13.4.1",
"qrcode": "^1.5.4",
"sharp": "^0.34.5",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"xlsx": "^0.18.5",
"zod": "^3.23.8"
}
},
"node_modules/@apidevtools/json-schema-ref-parser": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-14.0.1.tgz",
"integrity": "sha512-Oc96zvmxx1fqoSEdUmfmvvb59/KDOnUoJ7s2t7bISyAn0XEz57LCCw8k2Y4Pf3mwKaZLMciESALORLgfe2frCw==",
"license": "MIT",
"dependencies": {
"@types/json-schema": "^7.0.15",
"js-yaml": "^4.1.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/philsturgeon"
}
},
"node_modules/@apidevtools/openapi-schemas": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz",
"integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/@apidevtools/swagger-methods": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz",
"integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==",
"license": "MIT"
},
"node_modules/@apidevtools/swagger-parser": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-12.1.0.tgz",
"integrity": "sha512-e5mJoswsnAX0jG+J09xHFYQXb/bUc5S3pLpMxUuRUA2H8T2kni3yEoyz2R3Dltw5f4A6j6rPNMpWTK+iVDFlng==",
"license": "MIT",
"dependencies": {
"@apidevtools/json-schema-ref-parser": "14.0.1",
"@apidevtools/openapi-schemas": "^2.1.0",
"@apidevtools/swagger-methods": "^3.0.2",
"ajv": "^8.17.1",
"ajv-draft-04": "^1.0.0",
"call-me-maybe": "^1.0.2"
},
"peerDependencies": {
"openapi-types": ">=7"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
@@ -501,6 +551,15 @@
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@isaacs/cliui": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz",
"integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
}
},
"node_modules/@noble/hashes": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
@@ -569,6 +628,13 @@
"@otplib/core": "13.4.1"
}
},
"node_modules/@scarf/scarf": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
"integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==",
"hasInstallScript": true,
"license": "Apache-2.0"
},
"node_modules/@scure/base": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz",
@@ -578,6 +644,12 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"license": "MIT"
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -600,6 +672,36 @@
"node": ">=0.8"
}
},
"node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-draft-04": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz",
"integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==",
"license": "MIT",
"peerDependencies": {
"ajv": "^8.5.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@@ -630,12 +732,27 @@
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -752,6 +869,18 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/brace-expansion": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
@@ -837,6 +966,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/call-me-maybe": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz",
"integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==",
"license": "MIT"
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
@@ -903,6 +1038,15 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/commander": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz",
"integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/concat-stream": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
@@ -989,6 +1133,20 @@
"node": ">=0.8"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -1065,6 +1223,18 @@
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/doctrine": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
"integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
"license": "Apache-2.0",
"dependencies": {
"esutils": "^2.0.2"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -1166,6 +1336,15 @@
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
@@ -1245,6 +1424,28 @@
"express": ">= 4.11"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
"integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
@@ -1282,6 +1483,22 @@
"node": ">=8"
}
},
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"license": "ISC",
"dependencies": {
"cross-spawn": "^7.0.6",
"signal-exit": "^4.0.1"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -1376,6 +1593,30 @@
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/glob": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz",
"integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"license": "BlueOak-1.0.0",
"dependencies": {
"foreground-child": "^3.3.1",
"jackspeak": "^4.1.1",
"minimatch": "^10.1.1",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^2.0.0"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -1509,6 +1750,55 @@
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
"node_modules/jackspeak": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz",
"integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^9.0.0"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/js-yaml": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@@ -1606,12 +1896,27 @@
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.mergewith": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz",
"integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==",
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "11.5.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -1693,6 +1998,21 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
@@ -1702,6 +2022,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
@@ -1860,6 +2189,13 @@
"wrappy": "1"
}
},
"node_modules/openapi-types": {
"version": "12.1.3",
"resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz",
"integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==",
"license": "MIT",
"peer": true
},
"node_modules/otplib": {
"version": "13.4.1",
"resolved": "https://registry.npmjs.org/otplib/-/otplib-13.4.1.tgz",
@@ -1910,6 +2246,12 @@
"node": ">=6"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"license": "BlueOak-1.0.0"
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -1928,6 +2270,31 @@
"node": ">=8"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-scurry": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
@@ -2100,6 +2467,15 @@
"node": ">=0.10.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
@@ -2245,6 +2621,27 @@
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
@@ -2317,6 +2714,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
@@ -2441,6 +2850,50 @@
"node": ">=0.10.0"
}
},
"node_modules/swagger-jsdoc": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.3.0.tgz",
"integrity": "sha512-I+iQjVGV3t28pOkQUJv2MncthvOtkEactOn8R76SvSYhxgtIn7FoqfDHwQaN+GBnQdXQLrhgDXseKitmJcHMsA==",
"license": "MIT",
"dependencies": {
"@apidevtools/swagger-parser": "^12.1.0",
"commander": "6.2.0",
"doctrine": "3.0.0",
"glob": "11.1.0",
"lodash.mergewith": "^4.6.2",
"yaml": "2.0.0-1"
},
"bin": {
"swagger-jsdoc": "bin/swagger-jsdoc.js"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/swagger-ui-dist": {
"version": "5.32.8",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.8.tgz",
"integrity": "sha512-dgMdWXIgnI4zX4OPhKEdWnlDODbgm8W3AX0Ivn/BBqcUh6xZsBxhZMnvk6DJyRz1BTrj8dPxtarmEGgkz30oyA==",
"license": "Apache-2.0",
"dependencies": {
"@scarf/scarf": "=1.4.0"
}
},
"node_modules/swagger-ui-express": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz",
"integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==",
"license": "MIT",
"dependencies": {
"swagger-ui-dist": ">=5.0.0"
},
"engines": {
"node": ">= v0.10.32"
},
"peerDependencies": {
"express": ">=4.0.0 || >=5.0.0-beta"
}
},
"node_modules/tar-fs": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
@@ -2563,6 +3016,21 @@
"node": ">= 0.8"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
@@ -2643,6 +3111,15 @@
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.0.0-1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz",
"integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==",
"license": "ISC",
"engines": {
"node": ">= 6"
}
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
+2
View File
@@ -25,6 +25,8 @@
"otplib": "^13.4.1",
"qrcode": "^1.5.4",
"sharp": "^0.34.5",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"xlsx": "^0.18.5",
"zod": "^3.23.8"
},
+303 -14
View File
@@ -227,27 +227,45 @@ if (rembCols.includes('autres_taxes')) {
}
// ── Migration : renommage date_debut → date_premiere_echeance, date_echeance → date_cible ──
//
// ATTENTION (bug corrigé le 12/07/2026) : le bloc de "correction de formule" ci-dessous
// (SET date_cible = date_premiere_echeance + (duree_mois-1) mois, SANS clause IS NULL)
// s'exécutait auparavant à CHAQUE démarrage du serveur, pour TOUS les investissements,
// quel que soit type_remb. Combiné à la migration suivante (qui recopie date_cible dans
// date_premiere_echeance pour les prêts différés), cela créait une boucle : à chaque
// redémarrage, date_cible dérivait de +(duree_mois-1) mois supplémentaires, sans jamais
// être tracé dans investissement_historique. C'est la cause des dates aberrantes (parfois
// des siècles dans le futur) observées sur les prêts différés. La correction ne doit
// s'exécuter qu'UNE SEULE FOIS, au moment réel du renommage de colonne — jamais après.
{
const cols = db.prepare('PRAGMA table_info(investissements)').all().map(c => c.name);
const migrationEnCours = cols.includes('date_debut') || cols.includes('date_echeance');
if (cols.includes('date_debut')) {
db.exec('ALTER TABLE investissements RENAME COLUMN date_debut TO date_premiere_echeance');
}
if (cols.includes('date_echeance')) {
db.exec('ALTER TABLE investissements RENAME COLUMN date_echeance TO date_cible');
}
// Backfill date_premiere_echeance = souscription + 1 mois si vide
// Backfill date_premiere_echeance = souscription + 1 mois si vide (idempotent : ne touche
// jamais une valeur déjà renseignée)
db.exec(`UPDATE investissements
SET date_premiere_echeance = date(date_souscription, '+1 month')
WHERE date_premiere_echeance IS NULL AND date_souscription IS NOT NULL`);
// Backfill date_cible = date_premiere_echeance + (duree_mois - 1) mois si vide
// Backfill date_cible = date_premiere_echeance + (duree_mois - 1) mois si vide (idempotent)
// Formule cohérente avec la simulation : échéance 1 = date_premiere_echeance, dernière = +duree-1 mois
db.exec(`UPDATE investissements
SET date_cible = date(date_premiere_echeance, '+' || (duree_mois - 1) || ' months')
WHERE date_cible IS NULL AND date_premiere_echeance IS NOT NULL AND duree_mois IS NOT NULL`);
// Correction des date_cible déjà calculées avec l'ancienne formule (+duree_mois au lieu de +duree_mois-1)
db.exec(`UPDATE investissements
SET date_cible = date(date_premiere_echeance, '+' || (duree_mois - 1) || ' months')
WHERE date_premiere_echeance IS NOT NULL AND duree_mois IS NOT NULL`);
// Correction ponctuelle des date_cible déjà calculées avec l'ancienne formule
// (+duree_mois au lieu de +duree_mois-1) — ne s'exécute que le jour du renommage effectif
// des colonnes (migrationEnCours), jamais à chaque démarrage.
if (migrationEnCours) {
db.exec(`UPDATE investissements
SET date_cible = date(date_premiere_echeance, '+' || (duree_mois - 1) || ' months')
WHERE date_premiere_echeance IS NOT NULL AND duree_mois IS NOT NULL`);
}
}
// ── Migration : type_remb 'mensuel' → 'amortissable' + ajout freq_interets ──
@@ -275,19 +293,37 @@ if (!rembCols2.includes('interets_nets')) {
// ── Migration : prêts différés — date_premiere_echeance doit égaler date_cible ──
// (versement unique à l'échéance : les deux dates sont identiques)
{
const needsFix = db.prepare(`
SELECT COUNT(*) AS n FROM investissements
const aCorriger = db.prepare(`
SELECT id, nom_projet, date_premiere_echeance, date_cible
FROM investissements
WHERE type_remb = 'differe'
AND date_cible IS NOT NULL
AND (date_premiere_echeance IS NULL OR date_premiere_echeance != date_cible)
`).get().n;
`).all();
if (needsFix > 0) {
db.exec(`
UPDATE investissements
SET date_premiere_echeance = date_cible
WHERE type_remb = 'differe' AND date_cible IS NOT NULL
if (aCorriger.length > 0) {
const updateStmt = db.prepare(`
UPDATE investissements SET date_premiere_echeance = date_cible WHERE id = ?
`);
const histStmt = db.prepare(`
INSERT INTO investissement_historique (investissement_id, type_evenement, changements, notes)
VALUES (?, 'correction_auto_echeancier', ?, ?)
`);
for (const inv of aCorriger) {
updateStmt.run(inv.id);
histStmt.run(
inv.id,
JSON.stringify([{
champ: 'date_premiere_echeance',
label: 'Date 1ère échéance',
ancienne_valeur: inv.date_premiere_echeance,
nouvelle_valeur: inv.date_cible,
}]),
"Migration au démarrage : synchronisation avec date_cible (prêt différé — les deux dates doivent être identiques)",
);
}
// Régénère les simulations avec la nouvelle logique (date = startDate directement)
const differeInvs = db.prepare(`
SELECT id, montant_investi, taux_interet, duree_mois, type_remb, freq_interets,
@@ -393,6 +429,41 @@ db.exec(`
)
`);
// ── Migration : table des révisions de conditions de prêt (taux/date) ───
// Distincte de investissement_historique (qui est une piste d'audit générique
// auto-détectée sur tout changement de champ). Ici, on trace un événement métier
// explicite (retard projet, renégociation…) avec un motif obligatoire, et qui
// déclenche la régénération de l'échéancier (cf. routes/investissements.js).
db.exec(`
CREATE TABLE IF NOT EXISTS investissement_revisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
investissement_id INTEGER NOT NULL REFERENCES investissements(id) ON DELETE CASCADE,
date_effet TEXT NOT NULL,
ancien_taux REAL,
nouveau_taux REAL,
ancienne_date_cible TEXT,
nouvelle_date_cible TEXT,
motif TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_revisions_inv ON investissement_revisions(investissement_id)');
// ── Migration : traçage de la durée lors d'une révision de date cible ────
// nouvelle_date_cible ne suffisait pas à elle seule à modifier l'échéancier généré
// (generateSimul se base sur duree_mois, pas sur date_cible). Quand une révision change
// la date cible, duree_mois doit être recalculé en conséquence — on trace l'ancienne et
// la nouvelle valeur ici pour permettre un rollback fidèle (DELETE /revisions/:rid).
{
const revCols = db.prepare('PRAGMA table_info(investissement_revisions)').all().map(c => c.name);
if (!revCols.includes('ancien_duree_mois')) {
db.exec('ALTER TABLE investissement_revisions ADD COLUMN ancien_duree_mois INTEGER');
}
if (!revCols.includes('nouveau_duree_mois')) {
db.exec('ALTER TABLE investissement_revisions ADD COLUMN nouveau_duree_mois INTEGER');
}
}
// ── Migration : rôle utilisateur ─────────────────────────────────────────
{
const userCols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
@@ -1889,6 +1960,10 @@ console.log('[DB] Migrations 2FA OK');
db.exec("ALTER TABLE smtp_config ADD COLUMN min_password_length INTEGER NOT NULL DEFAULT 8");
console.log('[DB] Colonne smtp_config.min_password_length ajoutée');
}
if (!cols.includes('mcp_url')) {
db.exec("ALTER TABLE smtp_config ADD COLUMN mcp_url TEXT NOT NULL DEFAULT ''");
console.log('[DB] Colonne smtp_config.mcp_url ajoutée');
}
// ── Migration : email sur investisseurs ───────────────────────────────
const invColsEmail = db.prepare('PRAGMA table_info(investisseurs)').all().map(c => c.name);
@@ -2012,4 +2087,218 @@ console.log('[DB] Migrations 2FA OK');
}
}
// ── Backfill : garantir un profil principal + un compte courant par utilisateur ──
// Certains parcours de création de compte (admin, invitation) ne créaient pas
// systématiquement le profil investisseur principal et/ou son compte courant,
// contrairement à /auth/register. Ce backfill est idempotent (NOT EXISTS partout)
// et s'exécute à chaque démarrage pour rattraper les comptes existants.
{
// 1) Utilisateurs sans aucun investisseur (ex : comptes créés via invitation)
const usersWithoutInvestisseur = db.prepare(`
SELECT id, email, display_name FROM users u
WHERE NOT EXISTS (SELECT 1 FROM investisseurs i WHERE i.user_id = u.id)
`).all();
for (const u of usersWithoutInvestisseur) {
const fullName = u.display_name || u.email.split('@')[0];
const prenom = fullName.includes(' ') ? fullName.split(' ')[0] : null;
db.prepare(
`INSERT INTO investisseurs (user_id, nom, prenom, type, type_fiscal, is_principal) VALUES (?, ?, ?, 'famille', 'PP', 1)`
).run(u.id, fullName, prenom);
console.log(`[DB] backfill: profil investisseur principal créé pour user #${u.id} (${u.email})`);
}
// 2) Utilisateurs ayant des investisseurs mais aucun marqué principal (ex : comptes créés par un admin)
const usersWithoutPrincipal = db.prepare(`
SELECT DISTINCT user_id FROM investisseurs i
WHERE NOT EXISTS (SELECT 1 FROM investisseurs p WHERE p.user_id = i.user_id AND p.is_principal = 1)
`).all();
for (const { user_id } of usersWithoutPrincipal) {
const candidate = db.prepare(`
SELECT id FROM investisseurs WHERE user_id = ? ORDER BY (type = 'famille') DESC, id ASC LIMIT 1
`).get(user_id);
if (candidate) {
db.prepare('UPDATE investisseurs SET is_principal = 1 WHERE id = ?').run(candidate.id);
console.log(`[DB] backfill: investisseur #${candidate.id} marqué principal pour user #${user_id}`);
}
}
// 3) Investisseurs principaux sans compte courant
const principalsWithoutCompte = db.prepare(`
SELECT i.id AS investisseur_id, i.user_id, i.nom
FROM investisseurs i
WHERE i.is_principal = 1
AND NOT EXISTS (SELECT 1 FROM comptes c WHERE c.investisseur_id = i.id)
`).all();
for (const inv of principalsWithoutCompte) {
db.prepare(
'INSERT INTO comptes (user_id, nom, type, investisseur_id) VALUES (?,?,?,?)'
).run(inv.user_id, `Compte courant — ${inv.nom}`, 'compte_courant', inv.investisseur_id);
console.log(`[DB] backfill: compte courant créé pour l'investisseur principal #${inv.investisseur_id} (user #${inv.user_id})`);
}
}
// ── Migration : exclusions de tags hérités du référentiel sur plateformes ──
// Permet à l'utilisateur de retirer une catégorie/secteur hérité du référentiel
// sans que la fusion d'affichage ou un futur push référentiel ne la réinjecte.
{
const platColsExcl = db.prepare('PRAGMA table_info(plateformes)').all().map(c => c.name);
if (!platColsExcl.includes('excluded_categories_inv_ids')) {
db.exec("ALTER TABLE plateformes ADD COLUMN excluded_categories_inv_ids TEXT NOT NULL DEFAULT '[]'");
}
if (!platColsExcl.includes('excluded_secteurs_inv_ids')) {
db.exec("ALTER TABLE plateformes ADD COLUMN excluded_secteurs_inv_ids TEXT NOT NULL DEFAULT '[]'");
}
}
// ── Migration : compteur de doublons ignorés sur imports ──────────────────
// Distingue les lignes ignorées car identiques à une ligne déjà en base
// (rows_duplicates) des lignes ignorées pour une autre raison (rows_skipped
// reste le total ; rows_duplicates est un sous-ensemble informatif).
{
const importsCols = db.prepare('PRAGMA table_info(imports)').all().map(c => c.name);
if (!importsCols.includes('rows_duplicates')) {
db.exec('ALTER TABLE imports ADD COLUMN rows_duplicates INTEGER NOT NULL DEFAULT 0');
}
}
// ── Migration : plateforme_id en CASCADE (au lieu de RESTRICT) sur
// depots_retraits et investissements ────────────────────────────────────
// RESTRICT empêchait la suppression complète d'un compte (DELETE FROM users) :
// la cascade users→plateformes (user_id CASCADE) et users→investisseurs→
// depots_retraits/investissements (CASCADE) sont deux branches indépendantes
// de l'arbre de suppression ; SQLite peut supprimer une plateforme avant les
// lignes qui la référencent encore sur l'autre branche, ce qui déclenche le
// RESTRICT (erreur SQLITE_CONSTRAINT_TRIGGER). La protection "impossible de
// supprimer une plateforme qui a encore des données" est déplacée dans la
// route DELETE /api/plateformes/:id (vérification explicite avec message clair).
{
const fixPlateformeCascade = (tableName, indexStatements) => {
const row = db.prepare(
`SELECT sql FROM sqlite_master WHERE type='table' AND name=?`
).get(tableName);
if (!row || !/REFERENCES\s+plateformes\(id\)\s+ON DELETE RESTRICT/i.test(row.sql)) return;
const tempName = `__repair_${tableName}`;
const nameRe = new RegExp(
`CREATE TABLE\\s+(?:IF NOT EXISTS\\s+)?["'\`\\[]?${tableName}["'\`\\]]?`, 'i'
);
if (!nameRe.test(row.sql)) {
console.error(`[DB] migration plateforme_id CASCADE : nom de table non reconnu dans le DDL de "${tableName}", migration ignorée.`);
return;
}
const fixedDdl = row.sql
.replace(nameRe, `CREATE TABLE "${tempName}"`)
.replace(/REFERENCES\s+plateformes\(id\)\s+ON DELETE RESTRICT/i, 'REFERENCES plateformes(id) ON DELETE CASCADE');
const colDefs = db.prepare(`PRAGMA table_info("${tableName}")`).all();
const colNames = colDefs.map(c => `"${c.name}"`).join(', ');
const idxs = db.prepare(
`SELECT name FROM sqlite_master WHERE type='index' AND tbl_name=? AND sql IS NOT NULL`
).all(tableName);
db.exec('PRAGMA foreign_keys = OFF');
db.exec(`DROP TABLE IF EXISTS "${tempName}"`);
db.exec(fixedDdl);
db.exec(`INSERT INTO "${tempName}" (${colNames}) SELECT ${colNames} FROM "${tableName}"`);
for (const idx of idxs) db.exec(`DROP INDEX IF EXISTS "${idx.name}"`);
db.exec(`DROP TABLE "${tableName}"`);
db.exec('PRAGMA legacy_alter_table = ON');
db.exec(`ALTER TABLE "${tempName}" RENAME TO "${tableName}"`);
db.exec('PRAGMA legacy_alter_table = OFF');
db.exec('PRAGMA foreign_keys = ON');
for (const stmt of indexStatements) db.exec(stmt);
console.log(`[DB] migration : plateforme_id passé en ON DELETE CASCADE sur "${tableName}".`);
};
fixPlateformeCascade('depots_retraits', [
'CREATE INDEX IF NOT EXISTS idx_depret_inv ON depots_retraits(investisseur_id)',
'CREATE INDEX IF NOT EXISTS idx_depret_plat ON depots_retraits(plateforme_id)',
'CREATE INDEX IF NOT EXISTS idx_depret_date ON depots_retraits(date_operation)',
]);
fixPlateformeCascade('investissements', [
'CREATE INDEX IF NOT EXISTS idx_inv_inv ON investissements(investisseur_id)',
'CREATE INDEX IF NOT EXISTS idx_inv_plat ON investissements(plateforme_id)',
'CREATE INDEX IF NOT EXISTS idx_inv_statut ON investissements(statut)',
'CREATE INDEX IF NOT EXISTS idx_inv_date ON investissements(date_souscription)',
]);
}
// ── Migration : table objectifs ──────────────────────────────────────────
// Objectifs annuels par investisseur. `type` permet de réutiliser la table
// pour d'autres natures d'objectifs plus tard (ex: 'rendement_annuel') —
// pour l'instant seul 'versement_annuel' (objectif de dépôts nets) est utilisé.
db.exec(`
CREATE TABLE IF NOT EXISTS objectifs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
investisseur_id INTEGER NOT NULL REFERENCES investisseurs(id) ON DELETE CASCADE,
type TEXT NOT NULL DEFAULT 'versement_annuel',
annee INTEGER NOT NULL,
montant REAL NOT NULL,
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(investisseur_id, type, annee)
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_objectifs_investisseur ON objectifs(investisseur_id, type, annee)');
// ── Migration : suivi anti-doublon des notifications "données incomplètes" ──
// Stocke la signature (liste triée des champs essentiels manquants) au moment
// de la dernière notification envoyée pour cet investissement, afin que le job
// checkDonneesIncompletes ne notifie à nouveau que si ce manque a changé
// (nouveau champ manquant, champ comblé, ou tout comblé → signature NULL).
{
const cols = db.prepare('PRAGMA table_info(investissements)').all().map(c => c.name);
if (!cols.includes('donnees_incompletes_signature')) {
db.exec('ALTER TABLE investissements ADD COLUMN donnees_incompletes_signature TEXT');
console.log('[DB] investissements: colonne donnees_incompletes_signature ajoutée');
}
}
// ── Migration : table api_keys ───────────────────────────────────────────
// Clés API générées par l'utilisateur pour un accès programmatique/MCP.
// Chaque clé est scopée à un investisseur précis (usage single-user
// multi-investisseur — pas de notion multi-tenant ici). Seul le hash SHA-256
// est stocké ; la valeur en clair n'est montrée qu'une fois à la création.
// `key_prefix` (8 premiers caractères) permet d'identifier une clé dans
// l'UI sans jamais réafficher la valeur complète.
db.exec(`
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
investisseur_id INTEGER NOT NULL REFERENCES investisseurs(id) ON DELETE CASCADE,
nom TEXT NOT NULL,
key_prefix TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
scopes TEXT NOT NULL DEFAULT 'read',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_used_at TEXT,
revoked_at TEXT
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_api_keys_user ON api_keys(user_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_api_keys_inv ON api_keys(investisseur_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash)');
// ── Migration : clés API à scope "Famille et entreprises" ───────────────
// Une clé peut désormais couvrir tous les investisseurs du foyer plutôt
// qu'un seul (agrégation, en miroir du scope=all déjà utilisé par le
// frontend JWT). `investisseur_id` reste NOT NULL pour ne pas toucher à la
// contrainte existante : quand scope_all=1, la route de création force
// investisseur_id à pointer vers l'investisseur principal (ancrage FK),
// mais les routes /api/v1/* ignorent alors cette valeur au profit d'un
// filtre "tous les investisseurs de ce user_id" — voir apiKey.js et
// routes/v1/*.js. Seul le profil principal peut créer une clé scope_all=1
// (enforcement côté serveur dans routes/apiKeys.js, pas seulement l'UI).
{
const cols = db.prepare('PRAGMA table_info(api_keys)').all().map(c => c.name);
if (!cols.includes('scope_all')) {
db.exec('ALTER TABLE api_keys ADD COLUMN scope_all INTEGER NOT NULL DEFAULT 0');
console.log('[DB] api_keys: colonne scope_all ajoutée');
}
}
export default db;
+2 -2
View File
@@ -70,7 +70,7 @@ CREATE INDEX IF NOT EXISTS idx_plateformes_user ON plateformes(user_id);
CREATE TABLE IF NOT EXISTS depots_retraits (
id INTEGER PRIMARY KEY,
investisseur_id INTEGER NOT NULL REFERENCES investisseurs(id) ON DELETE CASCADE,
plateforme_id INTEGER NOT NULL REFERENCES plateformes(id) ON DELETE RESTRICT,
plateforme_id INTEGER NOT NULL REFERENCES plateformes(id) ON DELETE CASCADE,
date_operation TEXT NOT NULL, -- ISO YYYY-MM-DD
type TEXT NOT NULL CHECK(type IN ('depot','retrait')),
montant REAL NOT NULL CHECK(montant >= 0),
@@ -92,7 +92,7 @@ CREATE INDEX IF NOT EXISTS idx_depret_date ON depots_retraits(date_operation);
CREATE TABLE IF NOT EXISTS investissements (
id INTEGER PRIMARY KEY,
investisseur_id INTEGER NOT NULL REFERENCES investisseurs(id) ON DELETE CASCADE,
plateforme_id INTEGER NOT NULL REFERENCES plateformes(id) ON DELETE RESTRICT,
plateforme_id INTEGER NOT NULL REFERENCES plateformes(id) ON DELETE CASCADE,
nom_projet TEXT NOT NULL,
emetteur TEXT, -- nom de la société emprunteuse
date_souscription TEXT NOT NULL,
+134 -20
View File
@@ -14,29 +14,40 @@ function writeLog({ status, nbChanges, details, errorMsg }) {
}
}
/** Insère une notification utilisateur (best-effort, ne bloque jamais le job). */
function notifyUser(userId, { type, title, body, link }) {
try {
db.prepare(`
INSERT INTO notifications (user_id, type, title, body, link)
VALUES (?, ?, ?, ?, ?)
`).run(userId, type, title, body ?? null, link ?? null);
} catch (e) {
console.error('[autoStatut] Impossible de créer la notification :', e.message);
}
}
/**
* Passe automatiquement au statut "en_retard" les investissements dont :
* - le statut est actuellement "en_cours"
* - la date_cible est renseignée et strictement antérieure à aujourd'hui
*
* Chaque passage est tracé dans investissement_historique avec le
* type_evenement 'passage_auto_retard' pour conserver l'auditabilité.
* type_evenement 'passage_auto_retard' pour conserver l'auditabilité, et
* génère une notification utilisateur.
*
* @returns {number} nombre d'investissements mis à jour
*/
export function checkStatutsRetard() {
function checkPassagesEnRetard() {
const candidats = db.prepare(`
SELECT id, nom_projet, date_cible
FROM investissements
WHERE statut = 'en_cours'
AND date_cible IS NOT NULL
AND date_cible < date('now')
SELECT i.id, i.nom_projet, i.date_cible, inv.user_id
FROM investissements i
JOIN investisseurs inv ON inv.id = i.investisseur_id
WHERE i.statut = 'en_cours'
AND i.date_cible IS NOT NULL
AND i.date_cible < date('now')
`).all();
if (candidats.length === 0) {
writeLog({ status: 'ok', nbChanges: 0, details: 'Aucun investissement en retard détecté' });
return 0;
}
if (candidats.length === 0) return { nb: 0, details: [] };
const updateStmt = db.prepare(`
UPDATE investissements
@@ -63,22 +74,125 @@ export function checkStatutsRetard() {
}]),
`Passage automatique : date cible (${inv.date_cible}) dépassée`
);
notifyUser(inv.user_id, {
type: 'warning',
title: `Prêt en retard : "${inv.nom_projet}"`,
body: `La date cible (${inv.date_cible}) est dépassée sans remboursement enregistré. Le prêt est passé automatiquement au statut "En retard".`,
link: `/investissements/${inv.id}`,
});
}
});
tx();
const details = candidats
.map(i => `"${i.nom_projet}" (id=${i.id}, date_cible=${i.date_cible})`)
.join('; ');
return {
nb: candidats.length,
details: candidats.map(i => `"${i.nom_projet}" (id=${i.id}, date_cible=${i.date_cible})`),
};
}
writeLog({
status: 'ok',
nbChanges: candidats.length,
details: `Passé en retard : ${details}`,
/**
* Repasse automatiquement au statut "en_cours" les investissements dont :
* - le statut est actuellement "en_retard"
* - la date_cible est désormais renseignée à une échéance future plausible
* (entre aujourd'hui et +30 ans — cette borne haute évite qu'une date encore
* aberrante, ex. année 2400 non corrigée, ne soit prise à tort pour une date
* valide simplement parce qu'elle est "dans le futur")
* - le dernier événement ayant modifié le statut est bien le passage automatique
* en retard ('passage_auto_retard') — un passage en retard décidé manuellement
* par l'utilisateur n'est jamais annulé automatiquement.
*
* Typiquement déclenché après une correction de date_cible (ex : bouton
* "Corriger les dates des prêts différés" dans Nettoyage) qui repousse
* l'échéance dans le futur.
*
* @returns {number} nombre d'investissements mis à jour
*/
function checkRetoursEnCours() {
const candidats = db.prepare(`
SELECT i.id, i.nom_projet, i.date_cible, inv.user_id
FROM investissements i
JOIN investisseurs inv ON inv.id = i.investisseur_id
WHERE i.statut = 'en_retard'
AND i.date_cible IS NOT NULL
AND i.date_cible >= date('now')
AND i.date_cible <= date('now', '+30 years')
AND (
SELECT h.type_evenement FROM investissement_historique h
WHERE h.investissement_id = i.id
AND h.changements LIKE '%"champ":"statut"%'
ORDER BY h.created_at DESC, h.id DESC
LIMIT 1
) = 'passage_auto_retard'
`).all();
if (candidats.length === 0) return { nb: 0, details: [] };
const updateStmt = db.prepare(`
UPDATE investissements
SET statut = 'en_cours', updated_at = datetime('now')
WHERE id = ?
`);
const histStmt = db.prepare(`
INSERT INTO investissement_historique
(investissement_id, type_evenement, changements, notes)
VALUES (?, 'retour_auto_en_cours', ?, ?)
`);
const tx = db.transaction(() => {
for (const inv of candidats) {
updateStmt.run(inv.id);
histStmt.run(
inv.id,
JSON.stringify([{
champ: 'statut',
label: 'Statut',
ancienne_valeur: 'en_retard',
nouvelle_valeur: 'en_cours',
}]),
`Retour automatique : date cible (${inv.date_cible}) désormais future`
);
notifyUser(inv.user_id, {
type: 'success',
title: `Prêt de nouveau en cours : "${inv.nom_projet}"`,
body: `La date cible a été corrigée (${inv.date_cible}, désormais future). Le prêt repasse automatiquement au statut "En cours".`,
link: `/investissements/${inv.id}`,
});
}
});
tx();
console.log(`[autoStatut] ${candidats.length} investissement(s) passé(s) en retard : ${details}`);
return candidats.length;
return {
nb: candidats.length,
details: candidats.map(i => `"${i.nom_projet}" (id=${i.id}, date_cible=${i.date_cible})`),
};
}
/**
* Vérifie et applique les transitions automatiques de statut liées à la date_cible :
* en_cours → en_retard (échéance dépassée) et en_retard → en_cours (échéance corrigée
* dans le futur, uniquement si le passage en retard était lui-même automatique).
*
* @returns {number} nombre total d'investissements mis à jour
*/
export function checkStatutsRetard() {
const retards = checkPassagesEnRetard();
const retours = checkRetoursEnCours();
const nbChanges = retards.nb + retours.nb;
if (nbChanges === 0) {
writeLog({ status: 'ok', nbChanges: 0, details: 'Aucun changement de statut détecté' });
return 0;
}
const parts = [];
if (retards.nb > 0) parts.push(`Passé en retard : ${retards.details.join('; ')}`);
if (retours.nb > 0) parts.push(`Repassé en cours : ${retours.details.join('; ')}`);
const details = parts.join(' | ');
writeLog({ status: 'ok', nbChanges, details });
console.log(`[autoStatut] ${nbChanges} investissement(s) mis à jour : ${details}`);
return nbChanges;
}
/**
+130
View File
@@ -0,0 +1,130 @@
import db from '../db/index.js';
const JOB_NAME = 'check_donnees_incompletes';
// Champs essentiels vérifiés sur chaque investissement, avec leur libellé
// utilisé dans le corps de la notification.
const CHAMPS_ESSENTIELS = [
{ champ: 'taux_interet', label: "taux d'intérêt" },
{ champ: 'duree_mois', label: 'durée' },
{ champ: 'type_remb', label: 'type de remboursement' },
{ champ: 'date_premiere_echeance', label: 'date de première échéance' },
];
/** Persiste une entrée dans job_logs */
function writeLog({ status, nbChanges, details, errorMsg }) {
try {
db.prepare(`
INSERT INTO job_logs (job_name, status, nb_changes, details, error_msg)
VALUES (?, ?, ?, ?, ?)
`).run(JOB_NAME, status, nbChanges ?? 0, details ?? null, errorMsg ?? null);
} catch (e) {
console.error('[checkDonneesIncompletes] Impossible d\'écrire dans job_logs :', e.message);
}
}
/** Insère une notification utilisateur (best-effort, ne bloque jamais le job). */
function notifyUser(userId, { type, title, body, link }) {
try {
db.prepare(`
INSERT INTO notifications (user_id, type, title, body, link)
VALUES (?, ?, ?, ?, ?)
`).run(userId, type, title, body ?? null, link ?? null);
} catch (e) {
console.error('[checkDonneesIncompletes] Impossible de créer la notification :', e.message);
}
}
/**
* Vérifie que chaque investissement actif (statut != 'cloture') possède bien
* ses données essentielles : taux d'intérêt, durée, type de remboursement,
* date de première échéance.
*
* Anti-doublon : la signature triée des champs manquants est stockée dans
* investissements.donnees_incompletes_signature.
* - signature inchangée depuis la dernière notification → rien à faire
* - plus aucun champ manquant → signature réinitialisée (NULL), silencieux
* - signature différente (nouveau manque, ou manque partiellement comblé
* mais toujours incomplet) → nouvelle notification, signature mise à jour
*
* @returns {number} nombre de notifications envoyées
*/
export function checkDonneesIncompletes() {
const rows = db.prepare(`
SELECT i.id, i.nom_projet, i.taux_interet, i.duree_mois, i.type_remb,
i.date_premiere_echeance, i.donnees_incompletes_signature, inv.user_id
FROM investissements i
JOIN investisseurs inv ON inv.id = i.investisseur_id
WHERE i.statut != 'cloture'
`).all();
const updateSignature = db.prepare(`
UPDATE investissements SET donnees_incompletes_signature = ? WHERE id = ?
`);
let nbNotifs = 0;
const details = [];
const tx = db.transaction(() => {
for (const inv of rows) {
const manquants = CHAMPS_ESSENTIELS.filter(({ champ }) => {
const val = inv[champ];
return val === null || val === undefined || val === '';
});
const signature = manquants.length > 0
? manquants.map(m => m.champ).sort().join(',')
: null;
if (signature === inv.donnees_incompletes_signature) continue; // déjà à jour, rien à notifier
updateSignature.run(signature, inv.id);
if (signature) {
const labels = manquants.map(m => m.label).join(', ');
notifyUser(inv.user_id, {
type: 'warning',
title: `Données incomplètes : "${inv.nom_projet}"`,
body: `Champs manquants : ${labels}.`,
link: `/investissements/${inv.id}`,
});
nbNotifs++;
details.push(`"${inv.nom_projet}" (id=${inv.id}) : ${labels}`);
}
// sinon : données désormais complètes, on réinitialise juste la signature (pas de notification)
}
});
tx();
const summary = nbNotifs === 0 ? 'Aucune nouvelle notification' : details.join(' | ');
writeLog({ status: 'ok', nbChanges: nbNotifs, details: summary });
if (nbNotifs > 0) {
console.log(`[checkDonneesIncompletes] ${nbNotifs} notification(s) envoyée(s) : ${summary}`);
}
return nbNotifs;
}
/**
* Démarre le job de vérification des données essentielles manquantes.
* Exécution immédiate au démarrage, puis toutes les heures.
*/
export function startCheckDonneesIncompletesJob() {
const INTERVAL_MS = 60 * 60 * 1000; // 1 heure
try {
checkDonneesIncompletes();
} catch (err) {
console.error('[checkDonneesIncompletes] Erreur initiale :', err);
writeLog({ status: 'error', nbChanges: 0, errorMsg: err.message });
}
setInterval(() => {
try {
checkDonneesIncompletes();
} catch (err) {
console.error('[checkDonneesIncompletes] Erreur :', err);
writeLog({ status: 'error', nbChanges: 0, errorMsg: err.message });
}
}, INTERVAL_MS);
console.log('[checkDonneesIncompletes] Job démarré — vérification toutes les heures');
}
+42
View File
@@ -0,0 +1,42 @@
import crypto from 'node:crypto';
import db from '../db/index.js';
/**
* Authentification par clé API (X-API-Key), distincte du JWT utilisé par le
* frontend (requireAuth). Une clé API est scopée soit à un seul investisseur,
* soit à "Famille et entreprises" (scope_all=1, réservé au profil principal
* — voir routes/apiKeys.js) — en miroir du scope=all des routes internes.
* Réservé aux routes /api/v1 (lecture seule, Phase 1).
*
* Expose sur `req` :
* - req.investisseurId : id de l'investisseur si scope unique, sinon null
* - req.investisseurScopeAll: true si la clé couvre tout le foyer
* - req.userId : user_id du titulaire de la clé (toujours défini,
* utile pour le filtre "tous les investisseurs" quand scope_all)
*/
export function requireApiKey(req, res, next) {
const key = req.header('X-API-Key');
if (!key) {
return res.status(401).json({ error: 'Missing X-API-Key header' });
}
const hash = crypto.createHash('sha256').update(key).digest('hex');
const row = db.prepare(`
SELECT k.id, k.user_id, k.investisseur_id, k.scope_all, k.scopes, k.revoked_at
FROM api_keys k
WHERE k.key_hash = ?
`).get(hash);
if (!row || row.revoked_at) {
return res.status(401).json({ error: 'Invalid or revoked API key' });
}
db.prepare(`UPDATE api_keys SET last_used_at = datetime('now') WHERE id = ?`).run(row.id);
req.apiKeyId = row.id;
req.userId = row.user_id;
req.investisseurScopeAll = !!row.scope_all;
req.investisseurId = req.investisseurScopeAll ? null : row.investisseur_id;
req.apiScopes = (row.scopes || 'read').split(',').map(s => s.trim());
next();
}
+9 -2
View File
@@ -107,10 +107,17 @@ router.post('/users', (req, res, next) => {
const userId = result.lastInsertRowid;
const fullName = body.displayName || body.email.split('@')[0];
const prenom = fullName.includes(' ') ? fullName.split(' ')[0] : null;
db.prepare(
`INSERT INTO investisseurs (user_id, nom, prenom, type, type_fiscal) VALUES (?, ?, ?, 'famille', 'PP')`
// Auto-créer le profil principal (= l'utilisateur lui-même), comme dans /auth/register
const invResult = db.prepare(
`INSERT INTO investisseurs (user_id, nom, prenom, type, type_fiscal, is_principal) VALUES (?, ?, ?, 'famille', 'PP', 1)`
).run(userId, fullName, prenom);
// Auto-créer un compte courant pour le profil principal, comme dans /auth/register
db.prepare(
'INSERT INTO comptes (user_id, nom, type, investisseur_id) VALUES (?,?,?,?)'
).run(userId, `Compte courant — ${fullName}`, 'compte_courant', invResult.lastInsertRowid);
audit(req, { action: 'user_created', category: 'account', actorId: req.user.id, targetUserId: userId, details: { email: body.email, role: body.role, created_by_admin: true } });
res.status(201).json({ id: userId, email: body.email, display_name: body.displayName || null, role: body.role });
} catch (e) { next(e); }
+122
View File
@@ -0,0 +1,122 @@
import { Router } from 'express';
import crypto from 'node:crypto';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
const router = Router();
const KEY_PREFIX_LEN = 12; // ex: "clk_live_ab3" — assez pour identifier sans exposer le secret
function generateKey() {
const secret = crypto.randomBytes(24).toString('hex'); // 48 caractères hex
const full = `clk_live_${secret}`;
const hash = crypto.createHash('sha256').update(full).digest('hex');
return { full, hash, prefix: full.slice(0, KEY_PREFIX_LEN) };
}
/* ── GET /api/api-keys ── liste des clés de l'utilisateur connecté ──────── */
router.get('/', (req, res) => {
const rows = db.prepare(`
SELECT k.id, k.nom, k.key_prefix, k.scopes, k.investisseur_id, k.scope_all,
i.nom AS investisseur_nom, k.created_at, k.last_used_at, k.revoked_at
FROM api_keys k
JOIN investisseurs i ON i.id = k.investisseur_id
WHERE k.user_id = ?
ORDER BY k.revoked_at IS NOT NULL, k.created_at DESC
`).all(req.user.id);
res.json(rows);
});
/* ── POST /api/api-keys ── créer une nouvelle clé (nom + investisseur, ou
scope_all pour "Famille et entreprises") ──────────────────────────────
scope_all=true n'est autorisé que si investisseur_id désigne le profil
principal — enforcement serveur, indépendant de ce que montre l'UI, pour
qu'un appel direct à l'API ne puisse pas contourner cette règle. */
router.post('/', (req, res, next) => {
try {
const nom = (req.body?.nom || '').trim();
const investisseur_id = Number(req.body?.investisseur_id);
const scope_all = !!req.body?.scope_all;
if (!nom) throw new HttpError(400, 'Le nom de la clé est requis');
if (nom.length > 100) throw new HttpError(400, 'Le nom de la clé est trop long (100 caractères max)');
if (!Number.isInteger(investisseur_id)) throw new HttpError(400, 'investisseur_id est requis');
const inv = db.prepare('SELECT id, is_principal FROM investisseurs WHERE id = ? AND user_id = ?')
.get(investisseur_id, req.user.id);
if (!inv) throw new HttpError(404, 'Investisseur introuvable');
if (scope_all && !inv.is_principal) {
throw new HttpError(403, 'Seul le profil principal peut créer une clé « Famille et entreprises »');
}
const { full, hash, prefix } = generateKey();
const info = db.prepare(`
INSERT INTO api_keys (user_id, investisseur_id, nom, key_prefix, key_hash, scopes, scope_all)
VALUES (?, ?, ?, ?, ?, 'read', ?)
`).run(req.user.id, investisseur_id, nom, prefix, hash, scope_all ? 1 : 0);
const saved = db.prepare(`
SELECT k.id, k.nom, k.key_prefix, k.scopes, k.investisseur_id, k.scope_all,
i.nom AS investisseur_nom, k.created_at, k.last_used_at, k.revoked_at
FROM api_keys k JOIN investisseurs i ON i.id = k.investisseur_id
WHERE k.id = ?
`).get(info.lastInsertRowid);
// La valeur en clair n'est renvoyée qu'ici, une seule fois.
res.status(201).json({ ...saved, key: full });
} catch (e) { next(e); }
});
/* ── PATCH /api/api-keys/:id ── renommer une clé ─────────────────────────── */
router.patch('/:id', (req, res, next) => {
try {
const nom = (req.body?.nom || '').trim();
if (!nom) throw new HttpError(400, 'Le nom de la clé est requis');
if (nom.length > 100) throw new HttpError(400, 'Le nom de la clé est trop long (100 caractères max)');
const existing = db.prepare('SELECT id FROM api_keys WHERE id = ? AND user_id = ?')
.get(req.params.id, req.user.id);
if (!existing) throw new HttpError(404, 'Clé API introuvable');
db.prepare('UPDATE api_keys SET nom = ? WHERE id = ?').run(nom, req.params.id);
const saved = db.prepare(`
SELECT k.id, k.nom, k.key_prefix, k.scopes, k.investisseur_id, k.scope_all,
i.nom AS investisseur_nom, k.created_at, k.last_used_at, k.revoked_at
FROM api_keys k JOIN investisseurs i ON i.id = k.investisseur_id
WHERE k.id = ?
`).get(req.params.id);
res.json(saved);
} catch (e) { next(e); }
});
/* ── DELETE /api/api-keys/:id ── révoque la clé (soft-delete) ───────────── */
router.delete('/:id', (req, res, next) => {
try {
const existing = db.prepare('SELECT id, revoked_at FROM api_keys WHERE id = ? AND user_id = ?')
.get(req.params.id, req.user.id);
if (!existing) throw new HttpError(404, 'Clé API introuvable');
if (existing.revoked_at) return res.json({ revoked: true });
db.prepare(`UPDATE api_keys SET revoked_at = datetime('now') WHERE id = ?`).run(req.params.id);
res.json({ revoked: true });
} catch (e) { next(e); }
});
/* ── DELETE /api/api-keys/:id/purge ── suppression définitive de la ligne ──
Distinct de la révocation ci-dessus : ici la clé disparaît complètement
(active ou déjà révoquée). Le frontend affiche un avertissement avant
d'appeler cette route si la clé est encore active. ─────────────────── */
router.delete('/:id/purge', (req, res, next) => {
try {
const existing = db.prepare('SELECT id FROM api_keys WHERE id = ? AND user_id = ?')
.get(req.params.id, req.user.id);
if (!existing) throw new HttpError(404, 'Clé API introuvable');
db.prepare('DELETE FROM api_keys WHERE id = ?').run(req.params.id);
res.json({ deleted: true });
} catch (e) { next(e); }
});
export default router;
+27 -16
View File
@@ -28,8 +28,7 @@ const router = Router();
function checkPlatOwner(platId, userId) {
const row = db.prepare(`
SELECT p.id FROM plateformes p
JOIN investisseurs i ON i.id = p.investisseur_id
WHERE p.id = ? AND i.user_id = ?
WHERE p.id = ? AND p.user_id = ?
`).get(platId, userId);
if (!row) throw new HttpError(404, 'Plateforme introuvable ou accès refusé.');
return row;
@@ -80,15 +79,16 @@ router.get('/plateformes/:id/categories-inv', (req, res, next) => {
ORDER BY is_global DESC, c.nom
`).all(req.params.id);
// Merge tags du référentiel avec is_inherited: 1
const plat = db.prepare('SELECT referentiel_id FROM plateformes WHERE id = ?').get(req.params.id);
// Merge tags du référentiel avec is_inherited: 1 (sauf ceux explicitement retirés par l'utilisateur)
const plat = db.prepare('SELECT referentiel_id, excluded_categories_inv_ids FROM plateformes WHERE id = ?').get(req.params.id);
if (plat?.referentiel_id) {
const excludedIds = new Set(JSON.parse(plat.excluded_categories_inv_ids || '[]'));
const refCats = db.prepare(`
SELECT c.id, c.nom, CASE WHEN c.user_id IS NULL THEN 1 ELSE 0 END AS is_global
FROM referentiel_categories_inv rc
JOIN categories_inv c ON c.id = rc.categorie_id
WHERE rc.referentiel_id = ?
`).all(plat.referentiel_id);
`).all(plat.referentiel_id).filter(rc => !excludedIds.has(rc.id));
const refIdSet = new Set(refCats.map(r => r.id));
for (const row of rows) if (refIdSet.has(row.id)) row.is_inherited = 1;
const ownIds = new Set(rows.map(r => r.id));
@@ -108,20 +108,25 @@ router.put('/plateformes/:id/categories-inv', (req, res, next) => {
checkPlatOwner(req.params.id, userId);
checkCatIds(ids, userId);
// Toujours inclure les tags hérités du référentiel
// L'utilisateur peut retirer un tag hérité du référentiel : on respecte exactement
// sa sélection, et on mémorise les tags référentiel explicitement exclus pour que
// la fusion d'affichage (GET) et un futur push référentiel ne les réinjectent pas.
const plat = db.prepare('SELECT referentiel_id FROM plateformes WHERE id = ?').get(req.params.id);
let allIds = [...ids];
const idSet = new Set(ids);
let excludedIds = [];
if (plat?.referentiel_id) {
const refCatIds = db.prepare('SELECT categorie_id FROM referentiel_categories_inv WHERE referentiel_id = ?')
.all(plat.referentiel_id).map(r => r.categorie_id);
allIds = [...new Set([...refCatIds, ...ids])];
excludedIds = refCatIds.filter(id => !idSet.has(id));
}
db.transaction(() => {
db.prepare('DELETE FROM plateforme_categories_inv WHERE plateforme_id = ?').run(req.params.id);
const ins = db.prepare('INSERT INTO plateforme_categories_inv (plateforme_id, categorie_id) VALUES (?, ?)');
for (const id of allIds) ins.run(req.params.id, id);
syncInvestissementsCategories(req.params.id, allIds);
for (const id of ids) ins.run(req.params.id, id);
db.prepare('UPDATE plateformes SET excluded_categories_inv_ids = ? WHERE id = ?')
.run(JSON.stringify(excludedIds), req.params.id);
syncInvestissementsCategories(req.params.id, ids);
})();
res.json({ ok: true });
} catch (e) { next(e); }
@@ -140,14 +145,15 @@ router.get('/plateformes/:id/secteurs-inv', (req, res, next) => {
ORDER BY is_global DESC, s.nom
`).all(req.params.id);
const plat = db.prepare('SELECT referentiel_id FROM plateformes WHERE id = ?').get(req.params.id);
const plat = db.prepare('SELECT referentiel_id, excluded_secteurs_inv_ids FROM plateformes WHERE id = ?').get(req.params.id);
if (plat?.referentiel_id) {
const excludedIds = new Set(JSON.parse(plat.excluded_secteurs_inv_ids || '[]'));
const refSects = db.prepare(`
SELECT s.id, s.nom, CASE WHEN s.user_id IS NULL THEN 1 ELSE 0 END AS is_global
FROM referentiel_secteurs_inv rs
JOIN secteurs_inv s ON s.id = rs.secteur_id
WHERE rs.referentiel_id = ?
`).all(plat.referentiel_id);
`).all(plat.referentiel_id).filter(rs => !excludedIds.has(rs.id));
const refIdSet = new Set(refSects.map(r => r.id));
for (const row of rows) if (refIdSet.has(row.id)) row.is_inherited = 1;
const ownIds = new Set(rows.map(r => r.id));
@@ -167,19 +173,24 @@ router.put('/plateformes/:id/secteurs-inv', (req, res, next) => {
checkPlatOwner(req.params.id, userId);
checkSectIds(ids, userId);
// Même logique que pour les catégories : on respecte la sélection de l'utilisateur
// et on mémorise les secteurs référentiel explicitement exclus.
const plat = db.prepare('SELECT referentiel_id FROM plateformes WHERE id = ?').get(req.params.id);
let allIds = [...ids];
const idSet = new Set(ids);
let excludedIds = [];
if (plat?.referentiel_id) {
const refSectIds = db.prepare('SELECT secteur_id FROM referentiel_secteurs_inv WHERE referentiel_id = ?')
.all(plat.referentiel_id).map(r => r.secteur_id);
allIds = [...new Set([...refSectIds, ...ids])];
excludedIds = refSectIds.filter(id => !idSet.has(id));
}
db.transaction(() => {
db.prepare('DELETE FROM plateforme_secteurs_inv WHERE plateforme_id = ?').run(req.params.id);
const ins = db.prepare('INSERT INTO plateforme_secteurs_inv (plateforme_id, secteur_id) VALUES (?, ?)');
for (const id of allIds) ins.run(req.params.id, id);
syncInvestissementsSecteurs(req.params.id, allIds);
for (const id of ids) ins.run(req.params.id, id);
db.prepare('UPDATE plateformes SET excluded_secteurs_inv_ids = ? WHERE id = ?')
.run(JSON.stringify(excludedIds), req.params.id);
syncInvestissementsSecteurs(req.params.id, ids);
})();
res.json({ ok: true });
} catch (e) { next(e); }
+76 -19
View File
@@ -13,6 +13,24 @@ function purgeOldLogs() {
db.prepare("DELETE FROM audit_logs WHERE created_at < datetime('now', '-30 days')").run();
}
// ── Classification succès / avertissement / échec par action ──────────────
// IMPORTANT : garder cette liste synchronisée avec ACTION_STATUS dans
// frontend/src/pages/admin/AuditLogsSection.jsx (icônes + libellés).
const STATUS_ACTIONS = {
success: ['login_success', 'login_2fa_success', 'user_registered', 'user_created', 'email_verified_admin', 'invitation_accepted', 'invitation_sent', '2fa_enabled'],
warning: ['role_changed', 'status_changed', '2fa_disabled', 'user_deleted', 'account_self_deleted'],
failure: ['login_failed'],
};
const STATUS_CASE_SQL = `
CASE
WHEN al.action IN (${STATUS_ACTIONS.success.map(() => '?').join(',')}) THEN 'success'
WHEN al.action IN (${STATUS_ACTIONS.warning.map(() => '?').join(',')}) THEN 'warning'
WHEN al.action IN (${STATUS_ACTIONS.failure.map(() => '?').join(',')}) THEN 'failure'
ELSE NULL
END
`;
const STATUS_CASE_PARAMS = [...STATUS_ACTIONS.success, ...STATUS_ACTIONS.warning, ...STATUS_ACTIONS.failure];
// ── GET / — liste paginée avec filtres ────────────────────────────────────
router.get('/', (req, res, next) => {
try {
@@ -22,37 +40,48 @@ router.get('/', (req, res, next) => {
const limit = Math.min(Number(req.query.limit) || 50, 200);
const offset = (page - 1) * limit;
const category = req.query.category || null;
const status = req.query.status || null; // 'success' | 'warning' | 'failure'
const search = req.query.search || null; // filtre sur email acteur ou cible
const dateFrom = req.query.dateFrom || null;
const dateTo = req.query.dateTo || null;
const userId = req.query.userId ? Number(req.query.userId) : null;
const conditions = [];
const params = [];
// Conditions communes (hors statut) — réutilisées pour les compteurs par statut,
// qui doivent rester visibles/à jour même quand un statut est déjà sélectionné.
const baseConditions = [];
const baseParams = [];
if (category) {
conditions.push('al.category = ?');
params.push(category);
baseConditions.push('al.category = ?');
baseParams.push(category);
}
if (userId) {
conditions.push('(al.actor_id = ? OR al.target_user_id = ?)');
params.push(userId, userId);
baseConditions.push('(al.actor_id = ? OR al.target_user_id = ?)');
baseParams.push(userId, userId);
}
if (search) {
conditions.push('(actor.email LIKE ? OR target.email LIKE ? OR actor.display_name LIKE ? OR target.display_name LIKE ?)');
baseConditions.push('(actor.email LIKE ? OR target.email LIKE ? OR actor.display_name LIKE ? OR target.display_name LIKE ?)');
const like = `%${search}%`;
params.push(like, like, like, like);
baseParams.push(like, like, like, like);
}
if (dateFrom) {
conditions.push('al.created_at >= ?');
params.push(dateFrom);
baseConditions.push('al.created_at >= ?');
baseParams.push(dateFrom);
}
if (dateTo) {
conditions.push('al.created_at <= ?');
params.push(dateTo + ' 23:59:59');
baseConditions.push('al.created_at <= ?');
baseParams.push(dateTo + ' 23:59:59');
}
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const conditions = [...baseConditions];
const params = [...baseParams];
if (status) {
conditions.push(`(${STATUS_CASE_SQL}) = ?`);
params.push(...STATUS_CASE_PARAMS, status);
}
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const baseWhere = baseConditions.length ? `WHERE ${baseConditions.join(' AND ')}` : '';
const rows = db.prepare(`
SELECT
@@ -85,13 +114,41 @@ router.get('/', (req, res, next) => {
${where}
`).get(...params).n;
// Parser les details JSON
const parsed = rows.map(r => ({
...r,
details: r.details ? (() => { try { return JSON.parse(r.details); } catch { return r.details; } })() : null,
}));
// Compteurs par statut, calculés avec les mêmes filtres (catégorie/recherche/dates)
// mais SANS le filtre statut lui-même, pour permettre de basculer entre statuts.
const statusRows = db.prepare(`
SELECT (${STATUS_CASE_SQL}) AS ev_status, COUNT(*) AS n
FROM audit_logs al
LEFT JOIN users actor ON actor.id = al.actor_id
LEFT JOIN users target ON target.id = al.target_user_id
${baseWhere}
GROUP BY ev_status
`).all(...STATUS_CASE_PARAMS, ...baseParams);
res.json({ total, page, limit, rows: parsed });
const statusCounts = { success: 0, warning: 0, failure: 0 };
for (const r of statusRows) {
if (r.ev_status && statusCounts[r.ev_status] !== undefined) statusCounts[r.ev_status] = r.n;
}
// Parser les details JSON
const parsed = rows.map(r => {
const details = r.details ? (() => { try { return JSON.parse(r.details); } catch { return r.details; } })() : null;
// Repli sur les infos figées dans "details" quand l'utilisateur (acteur et/ou cible)
// a depuis été supprimé — son FK passe à NULL (ON DELETE SET NULL) mais l'email/nom
// saisis au moment de l'action restent lisibles pour les admins.
const fallbackName = details && typeof details === 'object' ? (details.display_name || null) : null;
const fallbackEmail = details && typeof details === 'object' ? (details.email || null) : null;
return {
...r,
details,
actor_name: r.actor_name ?? (r.actor_id == null ? fallbackName : null),
actor_email: r.actor_email ?? (r.actor_id == null ? fallbackEmail : null),
target_name: r.target_name ?? (r.target_id == null ? fallbackName : null),
target_email:r.target_email?? (r.target_id == null ? fallbackEmail: null),
};
});
res.json({ total, page, limit, rows: parsed, statusCounts });
} catch (e) { next(e); }
});
+70
View File
@@ -252,6 +252,76 @@ router.put('/me', requireAuth, async (req, res, next) => {
} catch (e) { next(e); }
});
// ── Suppression définitive du compte (self-service) ────────────────────────
const DeleteMeSchema = z.object({
password: z.string().min(1),
});
router.delete('/me', requireAuth, (req, res, next) => {
try {
const { password } = DeleteMeSchema.parse(req.body);
const user = db
.prepare('SELECT id, email, display_name, role, password_hash FROM users WHERE id = ?')
.get(req.user.id);
if (!user) throw new HttpError(404, 'Utilisateur introuvable');
const ok = bcrypt.compareSync(password, user.password_hash);
if (!ok) throw new HttpError(401, 'Mot de passe incorrect.');
// Empêche de se retrouver sans aucun administrateur sur l'application
if (user.role === 'admin') {
const { n: adminCount } = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin'").get();
if (adminCount <= 1) {
throw new HttpError(400, "Vous êtes le seul administrateur de l'application. Promouvez un autre compte en administrateur avant de supprimer le vôtre.");
}
}
// Log AVANT suppression : target_user_id/actor_id passeront à NULL après le DELETE
// (FK ON DELETE SET NULL), mais les informations restent lisibles dans "details".
audit(req, {
action: 'account_self_deleted',
category: 'account',
actorId: user.id,
targetUserId: user.id,
details: {
email: user.email,
display_name: user.display_name,
role: user.role,
initiated_by: 'self',
note: "Suppression de compte initiée par l'utilisateur lui-même depuis Mon compte.",
},
});
// Notifier les autres administrateurs
const otherAdmins = db.prepare("SELECT id FROM users WHERE role = 'admin' AND id != ?").all(user.id);
if (otherAdmins.length > 0) {
const insertNotif = db.prepare(
'INSERT INTO notifications (user_id, type, title, body, link) VALUES (?, ?, ?, ?, ?)'
);
const notifyTx = db.transaction((rows) => {
for (const admin of rows) {
insertNotif.run(
admin.id,
'security',
'Suppression de compte utilisateur',
`${user.display_name || user.email} (${user.email}) a supprimé définitivement son propre compte.`,
'/admin?section=audit-logs',
);
}
});
notifyTx(otherAdmins);
}
// Suppression définitive — cascade en base sur toutes les données liées
// (investisseurs, plateformes, investissements, remboursements, comptes,
// préférences, notifications, tickets, appareils de confiance, etc.)
db.prepare('DELETE FROM users WHERE id = ?').run(user.id);
res.status(204).end();
} catch (e) { next(e); }
});
// ── Vérification d'adresse email ──────────────────────────────────────────
router.get('/verify-email', (req, res, next) => {
try {
+1 -2
View File
@@ -22,8 +22,7 @@ router.get('/', (req, res) => {
(SELECT COUNT(*) FROM plateforme_categories_inv pc WHERE pc.categorie_id = c.id
AND pc.plateforme_id IN (
SELECT p.id FROM plateformes p
JOIN investisseurs i ON i.id = p.investisseur_id
WHERE i.user_id = ?
WHERE p.user_id = ?
)
) AS nb_plateformes,
(SELECT COUNT(*) FROM investissement_categories_inv ic WHERE ic.categorie_id = c.id
+6 -2
View File
@@ -29,10 +29,11 @@ function ensureRow() {
router.get('/', (_req, res, next) => {
try {
ensureRow();
const row = db.prepare('SELECT app_name, app_url, allow_registration, min_password_length FROM smtp_config WHERE id = 1').get();
const row = db.prepare('SELECT app_name, app_url, mcp_url, allow_registration, min_password_length FROM smtp_config WHERE id = 1').get();
res.json({
appName: row.app_name || 'Crowdlending Tracker',
appUrl: row.app_url || '',
mcpUrl: row.mcp_url || '',
allowRegistration: row.allow_registration !== 0,
minPasswordLength: row.min_password_length || 8,
});
@@ -42,6 +43,7 @@ router.get('/', (_req, res, next) => {
const PatchSchema = z.object({
appName: z.string().min(1).max(100).optional(),
appUrl: z.string().max(500).optional(),
mcpUrl: z.string().max(500).optional(),
allowRegistration: z.boolean().optional(),
minPasswordLength: z.number().int().min(6).max(64).optional(),
});
@@ -50,18 +52,20 @@ router.patch('/', (req, res, next) => {
try {
ensureRow();
const body = PatchSchema.parse(req.body);
const row = db.prepare('SELECT app_name, app_url, allow_registration, min_password_length FROM smtp_config WHERE id = 1').get();
const row = db.prepare('SELECT app_name, app_url, mcp_url, allow_registration, min_password_length FROM smtp_config WHERE id = 1').get();
db.prepare(`
UPDATE smtp_config SET
app_name = ?,
app_url = ?,
mcp_url = ?,
allow_registration = ?,
min_password_length = ?
WHERE id = 1
`).run(
body.appName !== undefined ? body.appName : (row.app_name || 'Crowdlending Tracker'),
body.appUrl !== undefined ? body.appUrl : (row.app_url || ''),
body.mcpUrl !== undefined ? body.mcpUrl : (row.mcp_url || ''),
body.allowRegistration !== undefined ? (body.allowRegistration ? 1 : 0) : (row.allow_registration !== 0 ? 1 : 0),
body.minPasswordLength !== undefined ? body.minPasswordLength : (row.min_password_length || 8),
);
+456 -41
View File
@@ -6,7 +6,10 @@ import fs from 'node:fs';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
import { requireInvestisseur } from '../middleware/investisseurScope.js';
import { generateSimul, generateSimulWithReinvestissements } from '../utils/schedule.js';
import { generateSimul, generateSimulWithReinvestissements, adjustSimulForActuals } from '../utils/schedule.js';
import { recordHistory, detectChangements, detectTypeEvenement } from './investissements.js';
import { checkDonneesIncompletes } from '../jobs/checkDonneesIncompletes.js';
import { syncInvestissementStatut } from './remboursements.js';
const router = Router();
@@ -92,7 +95,14 @@ router.post('/preview', upload.single('file'), (req, res, next) => {
} catch (e) { next(e); }
});
router.post('/apply', (req, res, next) => {
/**
* POST /api/imports/check-duplicates
* Même entrée que /apply (tempId, module, mapping, defaults) mais AUCUNE écriture en base :
* rejoue la détection anti-doublon module par module et renvoie, pour chaque ligne concernée,
* les valeurs de la ligne importée en regard de l'enregistrement existant en base, afin que
* l'utilisateur puisse décider ligne par ligne (modale de doublons) avant l'import réel.
*/
router.post('/check-duplicates', (req, res, next) => {
try {
const { tempId, module, mapping, defaults = {} } = req.body || {};
if (!tempId || !module || !mapping) {
@@ -101,6 +111,190 @@ router.post('/apply', (req, res, next) => {
const def = MODULES[module];
if (!def) throw new HttpError(400, 'Unknown module');
if (INVESTISSEUR_SCOPED.includes(module)) {
requireInvestisseur(req, res, () => {});
}
const tempPath = path.join(UPLOAD_DIR, tempId);
if (!fs.existsSync(tempPath)) throw new HttpError(404, 'Uploaded file expired');
const origName = req.body.originalFilename || '';
const { rows } = parseFile(tempPath, origName || 'file.xlsx');
let platIdSet = new Set(), platNameMap = new Map(), platNomById = new Map();
if (module === 'depots_retraits' || module === 'investissements') {
const platRows = db.prepare('SELECT id, nom FROM plateformes WHERE user_id = ?').all(req.user.id);
platIdSet = new Set(platRows.map(p => p.id));
platNameMap = new Map(platRows.map(p => [normalizeName(p.nom), p.id]));
platNomById = new Map(platRows.map(p => [p.id, p.nom]));
}
let invIdSet = new Set(), invNameMap = new Map(), invNomById = new Map();
if (module === 'remboursements') {
const invRows = db.prepare('SELECT id, nom_projet FROM investissements WHERE investisseur_id = ?').all(req.investisseur.id);
invIdSet = new Set(invRows.map(i => i.id));
invNameMap = new Map(invRows.map(i => [normalizeName(i.nom_projet), i.id]));
invNomById = new Map(invRows.map(i => [i.id, i.nom_projet]));
}
const duplicates = [];
// Doublons INTERNES au fichier : /apply traite les lignes une à une dans une seule transaction,
// donc une ligne répétée plus loin dans le même fichier se retrouve comparée à la précédente
// occurrence tout juste "insérée" — pas seulement aux enregistrements déjà présents en base.
// Comme cette route ne fait aucune écriture, on simule cet effet avec une map en mémoire :
// clé de dédoublonnage → { row, incoming } de la première occurrence rencontrée.
const seenDepotsRetraits = new Map();
const seenInvestissements = new Map();
const seenRemboursements = new Map();
const seenPlateformes = new Map();
const seenTauxPfu = new Map();
for (let idx = 0; idx < rows.length; idx++) {
const row = rows[idx];
const v = (target) => {
const col = mapping[target];
if (col && row[col] !== undefined && row[col] !== null && row[col] !== '') return row[col];
return defaults[target];
};
const rowNum = idx + 2;
try {
if (module === 'depots_retraits') {
const plateformeId = resolveRefId(v('plateforme_id'), platIdSet, platNameMap, 'Plateforme');
const dateOperation = normaliseDate(v('date_operation'));
const type = normaliseType(v('type'));
const montant = num(v('montant'));
const incoming = {
plateforme: platNomById.get(plateformeId) || '', date_operation: dateOperation,
type, montant, libelle: v('libelle') || null, reference: v('reference') || null,
};
const existing = db.prepare(`
SELECT * FROM depots_retraits
WHERE investisseur_id = ? AND plateforme_id = ? AND date_operation = ?
AND type = ? AND ABS(montant - ?) < 0.005
LIMIT 1
`).get(req.investisseur.id, plateformeId, dateOperation, type, montant);
const key = `${plateformeId}|${dateOperation}|${type}|${montant.toFixed(2)}`;
const seen = seenDepotsRetraits.get(key);
if (existing) {
duplicates.push({ row: rowNum, incoming, existing: {
plateforme: platNomById.get(existing.plateforme_id) || '', date_operation: existing.date_operation,
type: existing.type, montant: existing.montant, libelle: existing.libelle, reference: existing.reference,
} });
} else if (seen) {
duplicates.push({ row: rowNum, matchedRow: seen.row, incoming, existing: seen.incoming });
} else {
seenDepotsRetraits.set(key, { row: rowNum, incoming });
}
} else if (module === 'investissements') {
const plateformeId = resolveRefId(v('plateforme_id'), platIdSet, platNameMap, 'Plateforme');
const nomProjet = String(v('nom_projet'));
const dateSouscription = normaliseDate(v('date_souscription'));
const incoming = {
plateforme: platNomById.get(plateformeId) || '', nom_projet: nomProjet, date_souscription: dateSouscription,
montant_investi: num(v('montant_investi')), taux_interet: v('taux_interet') || null,
duree_mois: v('duree_mois') || null, statut: v('statut') || 'en_cours',
};
const existing = db.prepare(`
SELECT * FROM investissements
WHERE investisseur_id = ? AND plateforme_id = ? AND nom_projet = ? AND date_souscription = ?
LIMIT 1
`).get(req.investisseur.id, plateformeId, nomProjet, dateSouscription);
const key = `${plateformeId}|${normalizeName(nomProjet)}|${dateSouscription}`;
const seen = seenInvestissements.get(key);
if (existing) {
duplicates.push({ row: rowNum, incoming, existing: {
plateforme: platNomById.get(existing.plateforme_id) || '', nom_projet: existing.nom_projet,
date_souscription: existing.date_souscription, montant_investi: existing.montant_investi,
taux_interet: existing.taux_interet, duree_mois: existing.duree_mois, statut: existing.statut,
} });
} else if (seen) {
duplicates.push({ row: rowNum, matchedRow: seen.row, incoming, existing: seen.incoming });
} else {
seenInvestissements.set(key, { row: rowNum, incoming });
}
} else if (module === 'remboursements') {
const investissementId = resolveRefId(v('investissement_id'), invIdSet, invNameMap, 'Investissement');
const dateRemb = normaliseDate(v('date_remb'));
const capital = num(v('capital'));
const bruts = num(v('interets_bruts'));
const incoming = {
investissement: invNomById.get(investissementId) || '', date_remb: dateRemb, capital,
cashback: num(v('cashback')), interets_bruts: bruts,
prelev_sociaux: num(v('prelev_sociaux')), prelev_forfaitaire: num(v('prelev_forfaitaire')),
statut: v('statut') || 'paye',
};
const existing = db.prepare(`
SELECT * FROM remboursements
WHERE investissement_id = ? AND date_remb = ?
AND ABS(capital - ?) < 0.005 AND ABS(interets_bruts - ?) < 0.005
LIMIT 1
`).get(investissementId, dateRemb, capital, bruts);
const key = `${investissementId}|${dateRemb}|${capital.toFixed(2)}|${bruts.toFixed(2)}`;
const seen = seenRemboursements.get(key);
if (existing) {
duplicates.push({ row: rowNum, incoming, existing: {
investissement: invNomById.get(existing.investissement_id) || '', date_remb: existing.date_remb,
capital: existing.capital, cashback: existing.cashback, interets_bruts: existing.interets_bruts,
prelev_sociaux: existing.prelev_sociaux, prelev_forfaitaire: existing.prelev_forfaitaire,
statut: existing.statut,
} });
} else if (seen) {
duplicates.push({ row: rowNum, matchedRow: seen.row, incoming, existing: seen.incoming });
} else {
seenRemboursements.set(key, { row: rowNum, incoming });
}
} else if (module === 'plateformes') {
const nom = String(v('nom') || '').trim();
if (!nom) continue;
const incoming = { nom, url: v('url') || null, notes: v('notes') || null };
const existing = db.prepare('SELECT * FROM plateformes WHERE user_id = ? AND nom = ?').get(req.user.id, nom);
const key = nom;
const seen = seenPlateformes.get(key);
if (existing) {
duplicates.push({ row: rowNum, incoming, existing: { nom: existing.nom, url: existing.url, notes: existing.notes } });
} else if (seen) {
duplicates.push({ row: rowNum, matchedRow: seen.row, incoming, existing: seen.incoming });
} else {
seenPlateformes.set(key, { row: rowNum, incoming });
}
} else if (module === 'taux_pfu') {
const annee = parseInt(v('annee'), 10);
if (!annee) continue;
const incoming = {
annee, pfu_total: num(v('pfu_total')), impot_revenu: num(v('impot_revenu')), prelev_sociaux: num(v('prelev_sociaux')),
};
const existing = db.prepare('SELECT * FROM taux_pfu WHERE annee = ?').get(annee);
const key = annee;
const seen = seenTauxPfu.get(key);
if (existing) {
duplicates.push({ row: rowNum, incoming, existing: {
annee: existing.annee, pfu_total: existing.pfu_total, impot_revenu: existing.impot_revenu, prelev_sociaux: existing.prelev_sociaux,
} });
} else if (seen) {
duplicates.push({ row: rowNum, matchedRow: seen.row, incoming, existing: seen.incoming });
} else {
seenTauxPfu.set(key, { row: rowNum, incoming });
}
}
} catch {
// Une ligne dont les références ne se résolvent pas sera de toute façon signalée en erreur
// par /apply — on ne la fait pas échouer ici, la vérification des doublons est best-effort.
}
}
res.json({ total: rows.length, duplicates });
} catch (e) { next(e); }
});
router.post('/apply', (req, res, next) => {
try {
const { tempId, module, mapping, defaults = {}, duplicateDecisions = {} } = req.body || {};
if (!tempId || !module || !mapping) {
throw new HttpError(400, 'tempId, module and mapping are required');
}
const def = MODULES[module];
if (!def) throw new HttpError(400, 'Unknown module');
// Require a specific investisseur for transactional modules
if (INVESTISSEUR_SCOPED.includes(module)) {
requireInvestisseur(req, res, () => {});
@@ -122,8 +316,35 @@ router.post('/apply', (req, res, next) => {
const { rows } = parseFile(tempPath, origName || 'file.xlsx');
let inserted = 0, skipped = 0;
// Résolution plateforme_id / investissement_id : accepte un ID numérique OU un nom
// (ex. colonne "Plateforme" contenant "BienPrêter" plutôt qu'un ID) — cf. resolveRefId.
let platIdSet = new Set(), platNameMap = new Map();
if (module === 'depots_retraits' || module === 'investissements') {
const platRows = db.prepare('SELECT id, nom FROM plateformes WHERE user_id = ?').all(req.user.id);
platIdSet = new Set(platRows.map(p => p.id));
platNameMap = new Map(platRows.map(p => [normalizeName(p.nom), p.id]));
}
let invIdSet = new Set(), invNameMap = new Map();
if (module === 'remboursements') {
const invRows = db.prepare('SELECT id, nom_projet FROM investissements WHERE investisseur_id = ?').all(req.investisseur.id);
invIdSet = new Set(invRows.map(i => i.id));
invNameMap = new Map(invRows.map(i => [normalizeName(i.nom_projet), i.id]));
}
let inserted = 0, skipped = 0, duplicates = 0;
const errors = [];
// Investissements touchés par un remboursement importé (module 'remboursements') — sert à
// reproduire après coup ce que fait la création manuelle d'un remboursement (statut auto
// + réajustement de l'échéancier), que l'import ne faisait pas jusqu'ici.
const affectedInvestissementIds = new Set();
// Date la plus ancienne rencontrée par plateforme (dépôts/retraits + investissements)
// — sert à détecter une date antérieure à la date d'ouverture déclarée sur la plateforme.
const platMinDate = new Map();
const trackPlatDate = (plateformeId, dateStr) => {
if (!plateformeId || !dateStr) return;
const cur = platMinDate.get(plateformeId);
if (!cur || dateStr < cur) platMinDate.set(plateformeId, dateStr);
};
const tx = db.transaction(() => {
for (let idx = 0; idx < rows.length; idx++) {
@@ -136,24 +357,56 @@ router.post('/apply', (req, res, next) => {
}
return defaults[target];
};
// Décision prise par l'utilisateur dans la modale de doublons (voir /check-duplicates) :
// 'accept' = importer quand même malgré la correspondance ; absent/'skip' = comportement
// historique (la ligne en doublon est ignorée).
const rowDecision = duplicateDecisions[String(idx + 2)];
if (module === 'depots_retraits') {
const plateformeId = resolveRefId(v('plateforme_id'), platIdSet, platNameMap, 'Plateforme');
const dateOperation = normaliseDate(v('date_operation'));
const type = normaliseType(v('type'));
const montant = num(v('montant'));
trackPlatDate(plateformeId, dateOperation);
// Anti-doublon : même investisseur + plateforme + date + type + montant
const dup = db.prepare(`
SELECT id FROM depots_retraits
WHERE investisseur_id = ? AND plateforme_id = ? AND date_operation = ?
AND type = ? AND ABS(montant - ?) < 0.005
LIMIT 1
`).get(req.investisseur.id, plateformeId, dateOperation, type, montant);
if (dup && rowDecision !== 'accept') { duplicates++; continue; }
db.prepare(`
INSERT INTO depots_retraits
(investisseur_id, plateforme_id, date_operation, type, montant, libelle, reference, source)
VALUES (?,?,?,?,?,?,?,?)
`).run(
req.investisseur.id,
Number(v('plateforme_id')),
normaliseDate(v('date_operation')),
normaliseType(v('type')),
num(v('montant')),
plateformeId,
dateOperation,
type,
montant,
v('libelle') || null,
v('reference') || null,
srcLabel,
);
} else if (module === 'investissements') {
const plateformeId = resolveRefId(v('plateforme_id'), platIdSet, platNameMap, 'Plateforme');
const nomProjet = String(v('nom_projet'));
const dateSouscription = normaliseDate(v('date_souscription'));
trackPlatDate(plateformeId, dateSouscription);
// Anti-doublon : même investisseur + plateforme + nom du projet + date de souscription
const dup = db.prepare(`
SELECT id FROM investissements
WHERE investisseur_id = ? AND plateforme_id = ? AND nom_projet = ? AND date_souscription = ?
LIMIT 1
`).get(req.investisseur.id, plateformeId, nomProjet, dateSouscription);
if (dup && rowDecision !== 'accept') { duplicates++; continue; }
db.prepare(`
INSERT INTO investissements
(investisseur_id, plateforme_id, nom_projet, emetteur, date_souscription,
@@ -162,10 +415,10 @@ router.post('/apply', (req, res, next) => {
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
`).run(
req.investisseur.id,
Number(v('plateforme_id')),
String(v('nom_projet')),
plateformeId,
nomProjet,
v('emetteur') || null,
normaliseDate(v('date_souscription')),
dateSouscription,
v('date_premiere_echeance') ? normaliseDate(v('date_premiere_echeance')) : null,
v('date_cible') ? normaliseDate(v('date_cible')) : null,
num(v('montant_investi')),
@@ -179,6 +432,8 @@ router.post('/apply', (req, res, next) => {
);
} else if (module === 'remboursements') {
const investissementId = resolveRefId(v('investissement_id'), invIdSet, invNameMap, 'Investissement');
const dateRemb = normaliseDate(v('date_remb'));
const capital = num(v('capital'));
const cashback = num(v('cashback'));
const bruts = num(v('interets_bruts'));
@@ -186,37 +441,61 @@ router.post('/apply', (req, res, next) => {
const pf = num(v('prelev_forfaitaire'));
const interets_nets = Math.round((bruts - ps - pf) * 100) / 100;
const net_recu = Math.round((capital + cashback + interets_nets) * 100) / 100;
// Anti-doublon : même investissement + date + capital + intérêts bruts
const dup = db.prepare(`
SELECT id FROM remboursements
WHERE investissement_id = ? AND date_remb = ?
AND ABS(capital - ?) < 0.005 AND ABS(interets_bruts - ?) < 0.005
LIMIT 1
`).get(investissementId, dateRemb, capital, bruts);
if (dup && rowDecision !== 'accept') { duplicates++; continue; }
db.prepare(`
INSERT INTO remboursements
(investissement_id, date_remb, capital, cashback, interets_bruts, prelev_sociaux,
prelev_forfaitaire, interets_nets, net_recu, statut, source)
VALUES (?,?,?,?,?,?,?,?,?,?,?)
`).run(
Number(v('investissement_id')),
normaliseDate(v('date_remb')),
investissementId,
dateRemb,
capital, cashback, bruts, ps, pf, interets_nets, net_recu,
v('statut') || 'paye',
srcLabel,
);
affectedInvestissementIds.add(investissementId);
} else if (module === 'plateformes') {
const nom = String(v('nom') || '').trim();
if (!nom) throw new Error('Le champ nom est vide');
const r = db.prepare(`
INSERT OR IGNORE INTO plateformes (user_id, nom, url, notes)
VALUES (?, ?, ?, ?)
`).run(
req.user.id,
nom,
v('url') || null,
v('notes') || null,
);
// changes = 0 means the row was ignored (nom already exists)
if (r.changes === 0) throw new Error(`Plateforme "${nom}" existe déjà — ignorée`);
const existingPlat = db.prepare('SELECT id FROM plateformes WHERE user_id = ? AND nom = ?').get(req.user.id, nom);
if (existingPlat) {
// Une plateforme "nom" est unique par utilisateur/détenteur : impossible de créer un
// second doublon en base. "Accepter" le doublon signifie donc ici mettre à jour la
// fiche existante avec les valeurs importées, plutôt que de créer une nouvelle ligne.
if (rowDecision !== 'accept') { duplicates++; continue; }
db.prepare('UPDATE plateformes SET url = ?, notes = ? WHERE id = ?')
.run(v('url') || null, v('notes') || null, existingPlat.id);
} else {
db.prepare(`
INSERT INTO plateformes (user_id, nom, url, notes)
VALUES (?, ?, ?, ?)
`).run(
req.user.id,
nom,
v('url') || null,
v('notes') || null,
);
}
} else if (module === 'taux_pfu') {
const annee = parseInt(v('annee'), 10);
if (!annee || annee < 2000 || annee > 2100) throw new Error('Année invalide');
const existingTaux = db.prepare('SELECT annee FROM taux_pfu WHERE annee = ?').get(annee);
// Comportement historique : un taux existant est toujours écrasé par l'import (upsert).
// On ne change ce comportement que si l'utilisateur a explicitement refusé le doublon
// dans la modale de vérification — dans ce cas seulement, le taux existant est préservé.
if (existingTaux && rowDecision === 'skip') { duplicates++; continue; }
db.prepare(`
INSERT INTO taux_pfu (annee, pfu_total, impot_revenu, prelev_sociaux)
VALUES (?, ?, ?, ?)
@@ -242,9 +521,51 @@ router.post('/apply', (req, res, next) => {
});
tx();
// Vérifie et notifie les données essentielles manquantes (taux, durée, type de
// remboursement, date de première échéance) sur les investissements importés —
// même logique que le job horaire, best-effort pour ne jamais bloquer l'import.
if (module === 'investissements' && inserted > 0) {
try { checkDonneesIncompletes(); } catch (e) {
console.error('[imports] checkDonneesIncompletes après import a échoué :', e.message);
}
}
// Reproduit après import ce que fait la création manuelle d'un remboursement :
// passage automatique au statut "rembourse" si le capital est intégralement remboursé,
// et réajustement de l'échéancier (troncature des échéances futures devenues caduques en
// cas de remboursement anticipé). Sans cela, un investissement soldé via import restait
// affiché "En cours" avec un échéancier complet non réajusté (contrairement à la saisie manuelle).
if (module === 'remboursements' && affectedInvestissementIds.size > 0) {
for (const invId of affectedInvestissementIds) {
try {
syncInvestissementStatut(invId);
adjustSimulForActuals(db, invId);
} catch (e) {
console.error(`[imports] réajustement post-import échoué pour l'investissement #${invId} :`, e.message);
}
}
}
// Anomalie : une date importée précède la date d'ouverture déclarée sur la plateforme
const anomalies = [];
if (platMinDate.size > 0) {
for (const [plateformeId, minDate] of platMinDate) {
const plat = db.prepare('SELECT id, nom, date_ouverture FROM plateformes WHERE id = ? AND user_id = ?')
.get(plateformeId, req.user.id);
if (plat && plat.date_ouverture && minDate < plat.date_ouverture) {
anomalies.push({
plateforme_id: plat.id,
plateforme_nom: plat.nom,
date_ouverture_actuelle: plat.date_ouverture,
date_detectee: minDate,
});
}
}
}
db.prepare(`
INSERT INTO imports (user_id, investisseur_id, module, filename, rows_total, rows_inserted, rows_skipped, mapping_json)
VALUES (?,?,?,?,?,?,?,?)
INSERT INTO imports (user_id, investisseur_id, module, filename, rows_total, rows_inserted, rows_skipped, rows_duplicates, mapping_json)
VALUES (?,?,?,?,?,?,?,?,?)
`).run(
req.user.id,
req.investisseur?.id ?? null,
@@ -253,13 +574,51 @@ router.post('/apply', (req, res, next) => {
rows.length,
inserted,
skipped,
duplicates,
JSON.stringify(mapping),
);
// Clean up temp file
try { fs.unlinkSync(tempPath); } catch { /* */ }
res.json({ inserted, skipped, total: rows.length, errors: errors.slice(0, 50) });
res.json({ inserted, skipped, duplicates, total: rows.length, errors: errors.slice(0, 50), anomalies });
} catch (e) { next(e); }
});
/**
* POST /api/imports/template
* Génère, à partir du fichier déjà analysé (preview) et du mappage/valeurs par défaut choisis,
* un jeu de données complet : toutes les colonnes du module cible (obligatoires + optionnelles),
* y compris celles qui n'ont pas de colonne source dans le fichier (laissées vides). L'utilisateur
* peut ainsi compléter/enrichir ce fichier avant de le réimporter.
*/
router.post('/template', (req, res, next) => {
try {
const { tempId, module, mapping = {}, defaults = {} } = req.body || {};
if (!tempId || !module) throw new HttpError(400, 'tempId et module sont requis');
const def = MODULES[module];
if (!def) throw new HttpError(400, 'Unknown module');
const tempPath = path.join(UPLOAD_DIR, tempId);
if (!fs.existsSync(tempPath)) throw new HttpError(404, 'Uploaded file expired');
const origName = req.body.originalFilename || '';
const { rows } = parseFile(tempPath, origName || 'file.xlsx');
const allTargets = [...def.requiredTargets, ...def.optionalTargets];
const v = (row, target) => {
const col = mapping[target];
if (col && row[col] !== undefined && row[col] !== null && row[col] !== '') return row[col];
return defaults[target] !== undefined ? defaults[target] : '';
};
const templateRows = rows.map(row => {
const out = {};
for (const t of allTargets) out[t] = v(row, t);
return out;
});
res.json({ headers: allTargets, rows: templateRows });
} catch (e) { next(e); }
});
@@ -299,7 +658,7 @@ router.post('/dossier', (req, res, next) => {
/* ── 2. Chercher l'investissement existant (clé naturelle) */
const existing = db.prepare(`
SELECT id FROM investissements
SELECT * FROM investissements
WHERE investisseur_id = ? AND nom_projet = ? AND date_souscription = ?
LIMIT 1
`).get(req.investisseur.id, inv.nom_projet, inv.date_souscription);
@@ -381,6 +740,20 @@ router.post('/dossier', (req, res, next) => {
} else {
/* ────────────── SCÉNARIO UPDATE ──────────────────────── */
investissementId = existing.id;
const nouveau = {
plateforme_id: plateformeId,
date_premiere_echeance: inv.date_premiere_echeance || null,
date_cible: inv.date_cible || null,
date_debut_simul: inv.date_debut_simul || null,
montant_investi: Number(inv.montant_investi),
taux_interet: inv.taux_interet ?? null,
duree_mois: inv.duree_mois ?? null,
type_remb: inv.type_remb || 'in_fine',
freq_interets: inv.freq_interets || 'mensuel',
statut: inv.statut || 'en_cours',
};
db.prepare(`
UPDATE investissements SET
plateforme_id = ?, emetteur = ?,
@@ -390,11 +763,10 @@ router.post('/dossier', (req, res, next) => {
reference = ?, notes = ?
WHERE id = ?
`).run(
plateformeId, inv.emetteur || null,
inv.date_premiere_echeance || null, inv.date_cible || null, inv.date_debut_simul || null,
Number(inv.montant_investi), inv.taux_interet ?? null, inv.duree_mois ?? null,
inv.type_remb || 'in_fine', inv.freq_interets || 'mensuel',
inv.statut || 'en_cours',
nouveau.plateforme_id, inv.emetteur || null,
nouveau.date_premiere_echeance, nouveau.date_cible, nouveau.date_debut_simul,
nouveau.montant_investi, nouveau.taux_interet, nouveau.duree_mois,
nouveau.type_remb, nouveau.freq_interets, nouveau.statut,
inv.reference || null, inv.notes || null,
investissementId,
);
@@ -447,15 +819,29 @@ router.post('/dossier', (req, res, next) => {
}
}
// Entrée d'historique de la mise à jour
db.prepare(`
INSERT INTO investissement_historique (investissement_id, type_evenement, changements)
VALUES (?, 'import', ?)
`).run(investissementId, JSON.stringify([{
champ: 'import', label: 'Mise à jour dossier',
ancienne_valeur: null,
nouvelle_valeur: `${dossier.exported_at || 'inconnu'}${rembInserted} remb. ajouté(s), ${reinvInserted} réinvest. ajouté(s)`,
}]));
// Entrée d'historique de la mise à jour — diff précis champ par champ
// (même logique que l'édition manuelle, pour que la date_cible/date_premiere_echeance
// etc. écrasées par ce ré-import restent traçables dans l'historique du prêt).
const changements = detectChangements(existing, nouveau);
const notesImport = `Import dossier (${dossier.exported_at || 'date export inconnue'}) — ` +
`${rembInserted} remb. ajouté(s), ${reinvInserted} réinvest. ajouté(s)`;
if (changements.length > 0) {
recordHistory(investissementId, {
type_evenement: detectTypeEvenement(changements),
changements,
notes: notesImport,
});
} else {
recordHistory(investissementId, {
type_evenement: 'import',
changements: [{
champ: 'import', label: 'Mise à jour dossier',
ancienne_valeur: null,
nouvelle_valeur: notesImport,
}],
});
}
action = 'updated';
}
@@ -496,6 +882,35 @@ function stripAccents(s) {
return String(s).normalize('NFD').replace(/[̀-ͯ]/g, '');
}
/** Normalise un nom pour comparaison insensible à la casse/accents (ex. plateforme, projet) */
function normalizeName(s) {
return stripAccents(String(s || ''))
.toLowerCase()
.trim()
.replace(/\s+/g, ' ');
}
/**
* Résout une valeur de colonne (ID numérique OU nom texte) vers l'ID cible réel.
* - Si la valeur est un entier, elle doit correspondre à un ID existant (idSet).
* - Sinon, elle est recherchée par nom normalisé (nameMap) — insensible casse/accents.
* Lève une erreur explicite si rien ne correspond (la ligne sera ignorée par l'appelant).
*/
function resolveRefId(value, idSet, nameMap, label) {
if (value === undefined || value === null || String(value).trim() === '') {
throw new Error(`${label} manquant`);
}
const raw = String(value).trim();
if (/^\d+$/.test(raw)) {
const id = Number(raw);
if (idSet.has(id)) return id;
throw new Error(`${label} #${id} introuvable`);
}
const id = nameMap.get(normalizeName(raw));
if (id == null) throw new Error(`${label} "${raw}" introuvable`);
return id;
}
/** Convertit une valeur monétaire en nombre (gère "€", espaces, virgule décimale) */
function num(v) {
if (v === undefined || v === null || v === '') return 0;
+276 -10
View File
@@ -3,11 +3,12 @@ import { z } from 'zod';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
import { requireInvestisseur } from '../middleware/investisseurScope.js';
import { generateSimul } from '../utils/schedule.js';
import { generateSimul, generateSimulWithReinvestissements, monthsDiff } from '../utils/schedule.js';
import { checkStatutsRetard } from '../jobs/autoStatut.js';
const router = Router();
const TRACKED_FIELDS = [
export const TRACKED_FIELDS = [
{ key: 'type_remb', label: 'Type de prêt' },
{ key: 'taux_interet', label: 'Taux annuel (%)' },
{ key: 'duree_mois', label: 'Durée (mois)' },
@@ -20,7 +21,7 @@ const TRACKED_FIELDS = [
{ key: 'plateforme_id', label: 'Plateforme' },
];
function recordHistory(investissementId, { type_evenement, changements, notes }) {
export function recordHistory(investissementId, { type_evenement, changements, notes }) {
if (!changements || changements.length === 0) return;
db.prepare(`
INSERT INTO investissement_historique (investissement_id, type_evenement, changements, notes)
@@ -28,7 +29,7 @@ function recordHistory(investissementId, { type_evenement, changements, notes })
`).run(investissementId, type_evenement, JSON.stringify(changements), notes || null);
}
function detectChangements(ancien, nouveau) {
export function detectChangements(ancien, nouveau) {
const diffs = [];
for (const { key, label } of TRACKED_FIELDS) {
const av = ancien[key] ?? null;
@@ -42,12 +43,40 @@ function detectChangements(ancien, nouveau) {
return diffs;
}
function detectTypeEvenement(changements) {
export function detectTypeEvenement(changements) {
const champsRestructuration = ['type_remb', 'date_debut_simul'];
if (changements.some(c => champsRestructuration.includes(c.champ))) return 'restructuration';
return 'modification';
}
// Révision des conditions de prêt (taux et/ou date cible), suite à un événement
// (retard projet, renégociation…). Distinct de l'historique générique : motif obligatoire,
// table dédiée, déclenche la régénération automatique de l'échéancier futur.
const RevisionSchema = z.object({
date_effet: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
nouveau_taux: z.number().nonnegative().optional(),
nouvelle_date_cible: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
motif: z.string().trim().min(1, 'Le motif est obligatoire'),
}).refine(d => d.nouveau_taux !== undefined || d.nouvelle_date_cible !== undefined, {
message: 'Au moins un changement (nouveau taux ou nouvelle date cible) doit être renseigné',
});
function regenererEcheancier(investissementId) {
const hasReinvest = db.prepare(
'SELECT 1 FROM reinvestissements WHERE investissement_id = ? LIMIT 1'
).get(investissementId);
if (hasReinvest) {
generateSimulWithReinvestissements(db, investissementId);
} else {
const inv = db.prepare(`
SELECT id, montant_investi, taux_interet, duree_mois, type_remb, freq_interets,
date_premiere_echeance, date_debut_simul, date_souscription, echeance_fin_de_mois
FROM investissements WHERE id = ?
`).get(investissementId);
generateSimul(db, inv);
}
}
const Schema = z.object({
investisseur_id: z.number().int().positive().optional(),
plateforme_id: z.number().int().positive(),
@@ -173,14 +202,23 @@ router.get('/comptes-courants', (req, res) => {
res.json(rows);
});
// POST /api/investissements/fix-differe-dates
// POST /api/investissements/fix-differe-dates { seuilMois?: 3|6|12|18|24 }
// Corrige date_premiere_echeance et date_cible des prêts différés dont les dates
// s'écartent de plus de 2 ans par rapport à date_souscription + duree_mois.
// s'écartent de plus de `seuilMois` mois par rapport à date_souscription + duree_mois.
const SEUILS_MOIS_VALIDES = [3, 6, 12, 18, 24];
const SEUIL_MOIS_DEFAUT = 24;
router.post('/fix-differe-dates', (req, res, next) => {
try {
const seuilMois = SEUILS_MOIS_VALIDES.includes(Number(req.body?.seuilMois))
? Number(req.body.seuilMois)
: SEUIL_MOIS_DEFAUT;
const rows = db.prepare(`
SELECT i.id, i.nom_projet, i.date_souscription, i.duree_mois,
i.date_premiere_echeance, i.date_cible
i.date_premiere_echeance, i.date_cible,
i.montant_investi, i.taux_interet, i.type_remb, i.freq_interets,
i.date_debut_simul, i.echeance_fin_de_mois
FROM investissements i
JOIN investisseurs inv ON inv.id = i.investisseur_id AND inv.user_id = ?
WHERE i.type_remb = 'differe'
@@ -188,7 +226,9 @@ router.post('/fix-differe-dates', (req, res, next) => {
AND i.duree_mois IS NOT NULL
`).all(req.user.id);
const SEUIL_JOURS = 730; // 2 ans
// Approximation jours/mois (moyenne julienne) — cohérent avec l'ancien seuil fixe
// de 730 jours pour 24 mois (2 ans).
const SEUIL_JOURS = Math.round(seuilMois * 30.44);
function addMonths(dateStr, months) {
const d = new Date(dateStr + 'T00:00:00Z');
@@ -222,6 +262,46 @@ router.post('/fix-differe-dates', (req, res, next) => {
if (incoherent) {
stmt.run(dateCalculee, dateCalculee, inv.id);
const changements = [];
if (String(inv.date_premiere_echeance ?? null) !== String(dateCalculee)) {
changements.push({
champ: 'date_premiere_echeance',
label: 'Date 1ère échéance',
ancienne_valeur: inv.date_premiere_echeance,
nouvelle_valeur: dateCalculee,
});
}
if (String(inv.date_cible ?? null) !== String(dateCalculee)) {
changements.push({
champ: 'date_cible',
label: 'Date cible',
ancienne_valeur: inv.date_cible,
nouvelle_valeur: dateCalculee,
});
}
recordHistory(inv.id, {
type_evenement: 'correction_auto_dates',
changements,
notes: `Correction automatique (Nettoyage > Corriger les dates des prêts différés) : ` +
`écart > ${seuilMois} mois avec date_souscription + duree_mois`,
});
// Régénère l'échéancier de projection avec la date corrigée — sans ça,
// simul_remboursements reste calé sur l'ancienne date aberrante.
generateSimul(db, {
id: inv.id,
montant_investi: inv.montant_investi,
taux_interet: inv.taux_interet,
duree_mois: inv.duree_mois,
type_remb: inv.type_remb,
freq_interets: inv.freq_interets,
date_premiere_echeance: dateCalculee,
date_debut_simul: inv.date_debut_simul,
date_souscription: inv.date_souscription,
echeance_fin_de_mois: inv.echeance_fin_de_mois ?? 0,
});
corriges.push({
id: inv.id,
nom_projet: inv.nom_projet,
@@ -234,6 +314,86 @@ router.post('/fix-differe-dates', (req, res, next) => {
}
}
// Une date corrigée peut désormais être passée (prêt qui bascule en retard) ou au
// contraire future (prêt qui en sort) — on relance immédiatement la vérification de
// statut plutôt que d'attendre le prochain passage du job (minuit / redémarrage serveur),
// sinon le prêt affiche un statut incohérent avec sa date jusqu'au lendemain.
const statutsMisAJour = corriges.length > 0 ? checkStatutsRetard() : 0;
res.json({ updated: corriges.length, detail: corriges, statutsMisAJour, seuilMois });
} catch (err) {
next(err);
}
});
// POST /api/investissements/check-echeancier-differe
// Vérifie, pour chaque prêt différé, que l'échéancier de projection (simul_remboursements)
// contient bien une unique échéance dont la date correspond à date_premiere_echeance
// (== date_cible pour un prêt différé). Régénère l'échéancier sinon — utile après un
// import, une restauration, ou tout chemin ayant modifié les dates sans régénérer la
// simulation associée (cf. bouton "Corriger les dates des prêts différés" ci-dessus,
// qui ne couvre que l'écart date_souscription + durée > 2 ans).
router.post('/check-echeancier-differe', (req, res, next) => {
try {
const rows = db.prepare(`
SELECT i.id, i.nom_projet, i.date_premiere_echeance, i.date_cible,
i.montant_investi, i.taux_interet, i.duree_mois, i.type_remb, i.freq_interets,
i.date_debut_simul, i.date_souscription, i.echeance_fin_de_mois
FROM investissements i
JOIN investisseurs inv ON inv.id = i.investisseur_id AND inv.user_id = ?
WHERE i.type_remb = 'differe'
AND i.date_premiere_echeance IS NOT NULL
AND i.taux_interet IS NOT NULL
AND i.duree_mois IS NOT NULL
`).all(req.user.id);
const getSimul = db.prepare(`
SELECT id, numero_echeance, date_prevue
FROM simul_remboursements
WHERE investissement_id = ?
ORDER BY numero_echeance
`);
const hasReinvest = db.prepare('SELECT 1 FROM reinvestissements WHERE investissement_id = ? LIMIT 1');
const corriges = [];
for (const inv of rows) {
const simulRows = getSimul.all(inv.id);
const incoherent =
simulRows.length !== 1 ||
simulRows[0].date_prevue !== inv.date_premiere_echeance;
if (!incoherent) continue;
const ancienEcheancier = simulRows.length === 0
? 'aucune échéance'
: simulRows.map(s => `${s.date_prevue} (n°${s.numero_echeance})`).join(', ');
if (hasReinvest.get(inv.id)) {
generateSimulWithReinvestissements(db, inv.id);
} else {
generateSimul(db, inv);
}
recordHistory(inv.id, {
type_evenement: 'correction_auto_echeancier',
changements: [{
champ: 'echeancier',
label: 'Échéancier de projection',
ancienne_valeur: ancienEcheancier,
nouvelle_valeur: inv.date_premiere_echeance,
}],
notes: "Correction automatique (Nettoyage > Vérifier la cohérence de l'échéancier des prêts différés)",
});
corriges.push({
id: inv.id,
nom_projet: inv.nom_projet,
ancien_echeancier: ancienEcheancier,
nouvelle_date: inv.date_premiere_echeance,
});
}
res.json({ updated: corriges.length, detail: corriges });
} catch (err) {
next(err);
@@ -262,6 +422,9 @@ router.get('/:id', (req, res, next) => {
const historique = db.prepare(
'SELECT * FROM investissement_historique WHERE investissement_id = ? ORDER BY created_at ASC'
).all(req.params.id).map(h => ({ ...h, changements: JSON.parse(h.changements) }));
const revisions = db.prepare(
'SELECT * FROM investissement_revisions WHERE investissement_id = ? ORDER BY id ASC'
).all(req.params.id);
const reinvestissements = db.prepare(
'SELECT * FROM reinvestissements WHERE investissement_id = ? ORDER BY date_reinvestissement'
).all(req.params.id);
@@ -282,7 +445,7 @@ router.get('/:id', (req, res, next) => {
WHERE is2.investissement_id = ?
ORDER BY is_global DESC, s.nom
`).all(req.params.id);
res.json({ ...inv, capital_total, reinvestissements_total, remboursements, simul, historique, reinvestissements, categories_inv, secteurs_inv });
res.json({ ...inv, capital_total, reinvestissements_total, remboursements, simul, historique, revisions, reinvestissements, categories_inv, secteurs_inv });
} catch (e) { next(e); }
});
@@ -439,6 +602,109 @@ router.delete('/:id/historique/:hid', (req, res, next) => {
} catch (e) { next(e); }
});
// POST /api/investissements/:id/revisions
// Enregistre une révision des conditions de prêt (nouveau taux et/ou nouvelle date cible),
// avec motif obligatoire. Écrase taux_interet/date_cible de l'investissement, positionne
// date_debut_simul sur la date d'effet, et régénère automatiquement l'échéancier futur.
//
// Important : generateSimul() calcule l'échéancier à partir de duree_mois (pas de date_cible,
// qui n'est qu'un champ d'affichage/contractuel). Donc si nouvelle_date_cible est fournie et
// diffère de la date actuelle, duree_mois doit être recalculé en conséquence, sinon la
// nouvelle date cible ne serait que cosmétique et l'échéancier généré ne la refléterait pas.
router.post('/:id/revisions', (req, res, next) => {
try {
const body = RevisionSchema.parse(req.body);
const invId = Number(req.params.id);
const inv = db.prepare(`
SELECT i.* FROM investissements i
JOIN investisseurs inv ON inv.id = i.investisseur_id
WHERE i.id = ? AND inv.user_id = ?
`).get(invId, req.user.id);
if (!inv) throw new HttpError(404, 'Investissement introuvable');
const ancien_taux = inv.taux_interet ?? null;
const ancienne_date_cible = inv.date_cible ?? null;
const ancien_duree_mois = inv.duree_mois ?? null;
const nouveau_taux = body.nouveau_taux ?? ancien_taux;
const nouvelle_date_cible = body.nouvelle_date_cible ?? ancienne_date_cible;
// Recalcule duree_mois si la date cible change réellement, pour que l'échéancier régénéré
// atteigne effectivement cette nouvelle date (cf. convention date_cible = date_premiere_echeance
// + (duree_mois - 1) mois, utilisée côté frontend pour tous les types de prêt).
let nouveau_duree_mois = ancien_duree_mois;
if (nouvelle_date_cible !== ancienne_date_cible && inv.date_premiere_echeance) {
nouveau_duree_mois = monthsDiff(inv.date_premiere_echeance, nouvelle_date_cible) + 1;
}
let revisionId;
const tx = db.transaction(() => {
const r = db.prepare(`
INSERT INTO investissement_revisions
(investissement_id, date_effet, ancien_taux, nouveau_taux, ancienne_date_cible, nouvelle_date_cible,
ancien_duree_mois, nouveau_duree_mois, motif)
VALUES (?,?,?,?,?,?,?,?,?)
`).run(invId, body.date_effet, ancien_taux, nouveau_taux, ancienne_date_cible, nouvelle_date_cible,
ancien_duree_mois, nouveau_duree_mois, body.motif);
revisionId = r.lastInsertRowid;
db.prepare(`
UPDATE investissements
SET taux_interet = ?, date_cible = ?, duree_mois = ?, date_debut_simul = ?, updated_at = datetime('now')
WHERE id = ?
`).run(nouveau_taux, nouvelle_date_cible, nouveau_duree_mois, body.date_effet, invId);
regenererEcheancier(invId);
});
tx();
const revision = db.prepare('SELECT * FROM investissement_revisions WHERE id = ?').get(revisionId);
res.status(201).json(revision);
} catch (e) { next(e); }
});
// DELETE /api/investissements/:id/revisions/:rid
// Seule la révision la plus récente peut être supprimée (rollback vers l'état précédent,
// puis régénération de l'échéancier). Évite les états incohérents en cas de suppression
// d'une révision intermédiaire.
router.delete('/:id/revisions/:rid', (req, res, next) => {
try {
const invId = Number(req.params.id);
const inv = db.prepare(`
SELECT i.id FROM investissements i
JOIN investisseurs inv ON inv.id = i.investisseur_id
WHERE i.id = ? AND inv.user_id = ?
`).get(invId, req.user.id);
if (!inv) throw new HttpError(404, 'Investissement introuvable');
const derniere = db.prepare(
'SELECT * FROM investissement_revisions WHERE investissement_id = ? ORDER BY id DESC LIMIT 1'
).get(invId);
if (!derniere || derniere.id !== Number(req.params.rid)) {
throw new HttpError(400, 'Seule la révision la plus récente peut être supprimée');
}
const tx = db.transaction(() => {
db.prepare(`
UPDATE investissements
SET taux_interet = ?, date_cible = ?, duree_mois = ?,
date_debut_simul = (
SELECT date_effet FROM investissement_revisions
WHERE investissement_id = ? AND id != ? ORDER BY id DESC LIMIT 1
),
updated_at = datetime('now')
WHERE id = ?
`).run(derniere.ancien_taux, derniere.ancienne_date_cible, derniere.ancien_duree_mois, invId, derniere.id, invId);
db.prepare('DELETE FROM investissement_revisions WHERE id = ?').run(derniere.id);
regenererEcheancier(invId);
});
tx();
res.status(204).end();
} catch (e) { next(e); }
});
// PUT /api/investissements/:id/auto-reinvest { active: true|false }
router.put('/:id/auto-reinvest', (req, res, next) => {
try {
+14 -2
View File
@@ -167,7 +167,8 @@ router.post('/:token/register', async (req, res, next) => {
const pendingUser = db.prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?) AND email_verified = 0').get(inv.email);
if (!pendingUser) throw new HttpError(409, 'Ce compte a déjà été activé ou supprimé.');
const hash = await bcrypt.hash(password, 12);
const hash = await bcrypt.hash(password, 12);
const fullName = displayName || nameFromEmail(inv.email);
// Finaliser le compte : mot de passe, nom, vérification
db.prepare(`
@@ -177,7 +178,18 @@ router.post('/:token/register', async (req, res, next) => {
email_verified = 1,
updated_at = datetime('now')
WHERE id = ?
`).run(hash, displayName || nameFromEmail(inv.email), pendingUser.id);
`).run(hash, fullName, pendingUser.id);
// Auto-créer le profil principal (= l'utilisateur lui-même) + son compte courant,
// comme dans /auth/register — sans quoi l'utilisateur invité se retrouve sans
// aucun investisseur ni compte courant à sa première connexion.
const prenom = fullName.includes(' ') ? fullName.split(' ')[0] : null;
const invResult = db.prepare(
`INSERT INTO investisseurs (user_id, nom, prenom, type, type_fiscal, is_principal) VALUES (?, ?, ?, 'famille', 'PP', 1)`
).run(pendingUser.id, fullName, prenom);
db.prepare(
'INSERT INTO comptes (user_id, nom, type, investisseur_id) VALUES (?,?,?,?)'
).run(pendingUser.id, `Compte courant — ${fullName}`, 'compte_courant', invResult.lastInsertRowid);
// Marquer l'invitation comme utilisée
db.prepare("UPDATE invitations SET used_at = datetime('now') WHERE id = ?").run(inv.id);
+88
View File
@@ -0,0 +1,88 @@
import { Router } from 'express';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
const router = Router();
const TYPE_DEFAUT = 'versement_annuel';
/** Valide et normalise le corps d'une requête POST */
function parseBody(body) {
const { investisseur_id, type = TYPE_DEFAUT, annee, montant, notes } = body;
if (!investisseur_id) throw new HttpError(400, 'investisseur_id est requis');
if (!type?.trim()) throw new HttpError(400, 'type est requis');
if (!Number.isInteger(Number(annee)) || Number(annee) < 2000 || Number(annee) > 2100) {
throw new HttpError(400, 'annee invalide');
}
if (montant === undefined || montant === null || Number.isNaN(Number(montant)) || Number(montant) < 0) {
throw new HttpError(400, 'montant doit être un nombre positif');
}
return {
investisseur_id: Number(investisseur_id),
type: type.trim(),
annee: Number(annee),
montant: Number(montant),
notes: notes?.trim() || null,
};
}
/* ── GET /api/objectifs?type=&annee= ─────────────────────────────────────
Retourne tous les objectifs des investisseurs de l'utilisateur connecté
(toutes plateformes / tout le portefeuille — pas de notion de scope=all
vs single ici, un objectif appartient toujours à un investisseur donné). */
router.get('/', (req, res) => {
const { type, annee } = req.query;
const conds = ['i.user_id = ?'];
const args = [req.user.id];
if (type) { conds.push('o.type = ?'); args.push(type); }
if (annee) { conds.push('o.annee = ?'); args.push(Number(annee)); }
const rows = db.prepare(`
SELECT o.*, i.nom AS investisseur_nom
FROM objectifs o
JOIN investisseurs i ON i.id = o.investisseur_id
WHERE ${conds.join(' AND ')}
ORDER BY o.annee DESC, i.nom
`).all(...args);
res.json(rows);
});
/* ── POST /api/objectifs ── upsert (investisseur_id, type, annee) ───────── */
router.post('/', (req, res, next) => {
try {
const data = parseBody(req.body);
const inv = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?')
.get(data.investisseur_id, req.user.id);
if (!inv) throw new HttpError(404, 'Investisseur introuvable');
db.prepare(`
INSERT INTO objectifs (investisseur_id, type, annee, montant, notes)
VALUES (?,?,?,?,?)
ON CONFLICT(investisseur_id, type, annee)
DO UPDATE SET montant = excluded.montant, notes = excluded.notes, updated_at = datetime('now')
`).run(data.investisseur_id, data.type, data.annee, data.montant, data.notes);
const saved = db.prepare(`
SELECT o.*, i.nom AS investisseur_nom
FROM objectifs o JOIN investisseurs i ON i.id = o.investisseur_id
WHERE o.investisseur_id = ? AND o.type = ? AND o.annee = ?
`).get(data.investisseur_id, data.type, data.annee);
res.status(201).json(saved);
} catch (e) { next(e); }
});
/* ── DELETE /api/objectifs/:id ────────────────────────────────────────── */
router.delete('/:id', (req, res, next) => {
try {
const existing = db.prepare(`
SELECT o.id FROM objectifs o
JOIN investisseurs i ON i.id = o.investisseur_id
WHERE o.id = ? AND i.user_id = ?
`).get(req.params.id, req.user.id);
if (!existing) throw new HttpError(404, 'Objectif introuvable');
db.prepare('DELETE FROM objectifs WHERE id = ?').run(req.params.id);
res.json({ deleted: true });
} catch (e) { next(e); }
});
export default router;
+95 -9
View File
@@ -143,7 +143,8 @@ function attachCategoriesInv(rows) {
return rows.map(r => {
const ownTags = map[r.id] || [];
if (!r.referentiel_id) return { ...r, categories_inv: ownTags };
const refTags = refCatMap[r.referentiel_id] || [];
const excludedIds = new Set(JSON.parse(r.excluded_categories_inv_ids || '[]'));
const refTags = (refCatMap[r.referentiel_id] || []).filter(t => !excludedIds.has(t.id));
const refIdSet = new Set(refTags.map(t => t.id));
const ownIds = new Set(ownTags.map(t => t.id));
for (const tag of ownTags) tag.is_inherited = refIdSet.has(tag.id);
@@ -192,7 +193,8 @@ function attachSecteursInv(rows) {
return rows.map(r => {
const ownTags = map[r.id] || [];
if (!r.referentiel_id) return { ...r, secteurs_inv: ownTags };
const refTags = refSectMap[r.referentiel_id] || [];
const excludedIds = new Set(JSON.parse(r.excluded_secteurs_inv_ids || '[]'));
const refTags = (refSectMap[r.referentiel_id] || []).filter(t => !excludedIds.has(t.id));
const refIdSet = new Set(refTags.map(t => t.id));
const ownIds = new Set(ownTags.map(t => t.id));
for (const tag of ownTags) tag.is_inherited = refIdSet.has(tag.id);
@@ -218,8 +220,9 @@ function syncCategories(platId, catIds) {
// ── Lecture référentiel (tous users authentifiés) ─────────────────────────
router.get('/referentiel-list', (_req, res) => {
const rows = db.prepare(`
SELECT pr.id, pr.nom, pr.domiciliation, pr.fiscalite,
pr.taux_fiscalite_locale, pr.type_produit_fiscal, pr.logo_filename, pr.icone_filename
SELECT pr.id, pr.nom, pr.url, pr.domiciliation, pr.fiscalite,
pr.taux_fiscalite_locale, pr.type_produit_fiscal, pr.logo_filename, pr.icone_filename,
pr.methode_remboursement, pr.type_pret_defaut, pr.freq_interets_defaut
FROM plateformes_referentiel pr
ORDER BY pr.nom
`).all();
@@ -233,6 +236,7 @@ router.get('/', (req, res) => {
p.methode_remboursement, p.investisseur_id, p.date_ouverture, p.logo_filename, p.icone_filename, p.created_at,
p.type_pret_defaut, p.freq_interets_defaut,
p.referentiel_id, p.overridden_fields,
p.excluded_categories_inv_ids, p.excluded_secteurs_inv_ids,
pr.nom AS referentiel_nom, pr.description AS referentiel_description,
inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom,
inv.type AS investisseur_type, inv.type_fiscal AS investisseur_type_fiscal,
@@ -251,7 +255,10 @@ router.get('/', (req, res) => {
}));
const withCats = attachCategories(req.user.id, enriched);
const withCatsInv = attachCategoriesInv(withCats);
const withAll = attachSecteursInv(withCatsInv);
const withAll = attachSecteursInv(withCatsInv).map(r => {
const { excluded_categories_inv_ids, excluded_secteurs_inv_ids, ...rest } = r;
return rest;
});
res.json(withAll);
});
@@ -654,12 +661,34 @@ router.put('/:id', (req, res, next) => {
} catch (e) { next(e); }
});
// ── Correction rapide de la date d'ouverture (ex. anomalie détectée à l'import) ──
router.patch('/:id/date-ouverture', (req, res, next) => {
try {
const schema = z.object({ date_ouverture: z.string().regex(/^\d{4}-\d{2}-\d{2}$/) });
const { date_ouverture } = schema.parse(req.body);
const r = db.prepare('UPDATE plateformes SET date_ouverture = ? WHERE id = ? AND user_id = ?')
.run(date_ouverture, req.params.id, req.user.id);
if (r.changes === 0) throw new HttpError(404, 'Not found');
res.json({ id: Number(req.params.id), date_ouverture });
} catch (e) { next(e); }
});
router.delete('/:id', (req, res, next) => {
try {
// Récupère le logo avant suppression pour effacer le fichier
const plat = db.prepare('SELECT logo_filename FROM plateformes WHERE id=? AND user_id=?')
.get(req.params.id, req.user.id);
if (!plat) throw new HttpError(404, 'Not found');
// plateforme_id est en ON DELETE CASCADE sur investissements/depots_retraits (nécessaire pour
// permettre la suppression complète d'un compte) — on protège donc ici, explicitement, contre
// la suppression accidentelle d'une plateforme qui a encore des données rattachées.
const { n: nbInv } = db.prepare('SELECT COUNT(*) AS n FROM investissements WHERE plateforme_id = ?').get(req.params.id);
const { n: nbDr } = db.prepare('SELECT COUNT(*) AS n FROM depots_retraits WHERE plateforme_id = ?').get(req.params.id);
if (nbInv > 0 || nbDr > 0) {
throw new HttpError(400, `Impossible de supprimer cette plateforme : elle a encore ${nbInv} investissement(s) et ${nbDr} mouvement(s) de dépôt/retrait enregistrés. Supprimez-les d'abord.`);
}
const r = db.prepare('DELETE FROM plateformes WHERE id=? AND user_id=?')
.run(req.params.id, req.user.id);
if (r.changes === 0) throw new HttpError(404, 'Not found');
@@ -671,6 +700,61 @@ router.delete('/:id', (req, res, next) => {
} catch (e) { next(e); }
});
// ── Purge des données d'une plateforme (dépôts/retraits, investissements, remboursements) ──
// La plateforme elle-même n'est jamais supprimée par cette route : seules les données
// rattachées le sont, afin de permettre une réimportation propre derrière.
const PurgeSchema = z.object({
scope: z.enum(['all', 'depots_retraits', 'investissements', 'remboursements']),
confirmNom: z.string().min(1),
});
router.post('/:id/purge-donnees', (req, res, next) => {
try {
const { scope, confirmNom } = PurgeSchema.parse(req.body);
const plat = db.prepare('SELECT id, nom FROM plateformes WHERE id = ? AND user_id = ?')
.get(req.params.id, req.user.id);
if (!plat) throw new HttpError(404, 'Plateforme introuvable');
// Sécurité supplémentaire : le nom retapé doit correspondre exactement (protection
// contre une confirmation validée par erreur sur la mauvaise plateforme).
if (confirmNom.trim() !== plat.nom) {
throw new HttpError(400, 'Le nom saisi ne correspond pas exactement au nom de la plateforme.');
}
const platId = plat.id;
const counts = { depots_retraits: 0, investissements: 0, remboursements: 0, simul_remboursements: 0 };
const tx = db.transaction(() => {
if (scope === 'depots_retraits' || scope === 'all') {
counts.depots_retraits = db.prepare('DELETE FROM depots_retraits WHERE plateforme_id = ?').run(platId).changes;
}
if (scope === 'remboursements') {
// Ne touche que les remboursements réels ; les investissements et leur échéancier restent.
counts.remboursements = db.prepare(`
DELETE FROM remboursements
WHERE investissement_id IN (SELECT id FROM investissements WHERE plateforme_id = ?)
`).run(platId).changes;
}
if (scope === 'investissements' || scope === 'all') {
// Compte avant suppression : les remboursements et l'échéancier simulé sont
// supprimés en cascade (ON DELETE CASCADE) par la suppression des investissements.
counts.remboursements = db.prepare(`
SELECT COUNT(*) AS n FROM remboursements
WHERE investissement_id IN (SELECT id FROM investissements WHERE plateforme_id = ?)
`).get(platId).n;
counts.simul_remboursements = db.prepare(`
SELECT COUNT(*) AS n FROM simul_remboursements
WHERE investissement_id IN (SELECT id FROM investissements WHERE plateforme_id = ?)
`).get(platId).n;
counts.investissements = db.prepare('DELETE FROM investissements WHERE plateforme_id = ?').run(platId).changes;
}
});
tx();
res.json({ ok: true, scope, plateforme: plat.nom, counts });
} catch (e) { next(e); }
});
// ── Reset aux valeurs du référentiel ──────────────────────────────────────
router.post('/:id/reset', (req, res, next) => {
try {
@@ -684,12 +768,14 @@ router.post('/:id/reset', (req, res, next) => {
db.prepare(`
UPDATE plateformes
SET nom=?, domiciliation=?, fiscalite=?, taux_fiscalite_locale=?,
type_produit_fiscal=?, logo_filename=?, overridden_fields='[]'
SET nom=?, url=?, domiciliation=?, fiscalite=?, taux_fiscalite_locale=?,
type_produit_fiscal=?, logo_filename=?, methode_remboursement=?,
type_pret_defaut=?, freq_interets_defaut=?, overridden_fields='[]'
WHERE id=? AND user_id=?
`).run(
ref.nom, ref.domiciliation, ref.fiscalite, ref.taux_fiscalite_locale ?? null,
ref.type_produit_fiscal, ref.logo_filename ?? null,
ref.nom, ref.url ?? null, ref.domiciliation, ref.fiscalite, ref.taux_fiscalite_locale ?? null,
ref.type_produit_fiscal, ref.logo_filename ?? null, ref.methode_remboursement || 'portefeuille',
ref.type_pret_defaut ?? null, ref.freq_interets_defaut ?? null,
req.params.id, req.user.id
);
+12 -7
View File
@@ -825,7 +825,7 @@ router.post('/:id/push', (req, res, next) => {
).all(ref.id).map(r => r.secteur_id);
const plateformes = db.prepare(
'SELECT id, overridden_fields FROM plateformes WHERE referentiel_id = ?'
'SELECT id, overridden_fields, excluded_categories_inv_ids, excluded_secteurs_inv_ids FROM plateformes WHERE referentiel_id = ?'
).all(ref.id);
let nb_updated = 0;
@@ -853,14 +853,19 @@ router.post('/:id/push', (req, res, next) => {
}
// ── Catégories/secteurs ──
// Méthode dure (force) : réinitialise aussi les exclusions utilisateur et remplace toutes les associations.
// Méthode douce (défaut) : respecte les tags que l'utilisateur a explicitement retirés (excluded_*_ids).
const excludedCatIds = force ? new Set() : new Set(JSON.parse(plat.excluded_categories_inv_ids || '[]'));
const excludedSectIds = force ? new Set() : new Set(JSON.parse(plat.excluded_secteurs_inv_ids || '[]'));
if (force) {
// Méthode dure : remplace toutes les associations
db.prepare('DELETE FROM plateforme_categories_inv WHERE plateforme_id = ?').run(plat.id);
db.prepare('DELETE FROM plateforme_secteurs_inv WHERE plateforme_id = ?').run(plat.id);
db.prepare("UPDATE plateformes SET excluded_categories_inv_ids = '[]', excluded_secteurs_inv_ids = '[]' WHERE id = ?").run(plat.id);
}
// Méthode douce : INSERT OR IGNORE (n'écrase pas les associations existantes)
for (const catId of refCatIds) insCat.run(plat.id, catId);
for (const sectId of refSectIds) insSect.run(plat.id, sectId);
// INSERT OR IGNORE (n'écrase pas les associations existantes), en sautant les tags exclus par l'utilisateur
for (const catId of refCatIds) if (!excludedCatIds.has(catId)) insCat.run(plat.id, catId);
for (const sectId of refSectIds) if (!excludedSectIds.has(sectId)) insSect.run(plat.id, sectId);
// Sync investissements actifs de la plateforme
const invs = db.prepare(
@@ -871,8 +876,8 @@ router.post('/:id/push', (req, res, next) => {
db.prepare('DELETE FROM investissement_categories_inv WHERE investissement_id = ?').run(inv.id);
db.prepare('DELETE FROM investissement_secteurs_inv WHERE investissement_id = ?').run(inv.id);
}
for (const catId of refCatIds) insInvCat.run(inv.id, catId);
for (const sectId of refSectIds) insInvSect.run(inv.id, sectId);
for (const catId of refCatIds) if (!excludedCatIds.has(catId)) insInvCat.run(inv.id, catId);
for (const sectId of refSectIds) if (!excludedSectIds.has(sectId)) insInvSect.run(inv.id, sectId);
}
nb_updated++;
+1 -1
View File
@@ -86,7 +86,7 @@ function syncAutoRetrait(rembId, methode, netRecu, investissementId, dateRemb) {
);
}
function syncInvestissementStatut(investissement_id) {
export function syncInvestissementStatut(investissement_id) {
if (!investissement_id) return;
const inv = db.prepare('SELECT montant_investi FROM investissements WHERE id=?').get(investissement_id);
if (!inv) return;
+1 -2
View File
@@ -21,8 +21,7 @@ router.get('/', (req, res) => {
(SELECT COUNT(*) FROM plateforme_secteurs_inv ps WHERE ps.secteur_id = s.id
AND ps.plateforme_id IN (
SELECT p.id FROM plateformes p
JOIN investisseurs i ON i.id = p.investisseur_id
WHERE i.user_id = ?
WHERE p.user_id = ?
)
) AS nb_plateformes,
(SELECT COUNT(*) FROM investissement_secteurs_inv is2 WHERE is2.secteur_id = s.id
+3 -1
View File
@@ -102,7 +102,9 @@ router.post('/generate', (req, res, next) => {
const { investissement_id, replace = true } = req.body;
if (!investissement_id) throw new HttpError(400, 'investissement_id required');
const inv = assertOwnedInvestissement(Number(investissement_id), req.user.id);
if (!inv.taux_interet || !inv.duree_mois) {
// taux_interet peut légitimement valoir 0 (ex. révision suite à un arrêt de production) —
// ne pas confondre avec "non renseigné" (null/undefined).
if (inv.taux_interet == null || !inv.duree_mois) {
throw new HttpError(400, 'Investissement requires taux_interet and duree_mois');
}
+90
View File
@@ -0,0 +1,90 @@
import { Router } from 'express';
import db from '../../db/index.js';
const router = Router();
/**
* @openapi
* /dashboard:
* get:
* summary: Synthèse du portefeuille (KPIs)
* description: >
* Les champs `investissements.*` sont des soldes actuels (photo à
* aujourd'hui), pas des cumuls par période un investissement remboursé
* partiellement reste compté pour son capital restant tant qu'il
* n'est pas soldé. Seuls les champs `interets.*` peuvent être filtrés
* par année via `?annee=`.
* tags: [Dashboard]
* security: [{ ApiKeyAuth: [] }]
* parameters:
* - in: query
* name: annee
* schema: { type: integer }
* description: >
* Filtre les intérêts/capital reçu sur une année (ex. 2026).
* Omis = cumul sur toute la durée du portefeuille.
* responses:
* 200: { description: Synthèse KPI }
*/
router.get('/', (req, res) => {
const annee = req.query.annee ? Number(req.query.annee) : null;
// Clé "Famille et entreprises" (scope_all) → agrège tous les investisseurs
// du foyer (req.userId) ; clé mono-investisseur → filtre sur req.investisseurId.
// Dans les deux cas un seul paramètre suffit : soit l'id investisseur, soit
// le user_id pour la sous-requête IN (…) — en miroir de ?scope=all côté JWT.
const invCond = (col) => req.investisseurScopeAll
? `${col} IN (SELECT id FROM investisseurs WHERE user_id = ?)`
: `${col} = ?`;
const invParam = req.investisseurScopeAll ? req.userId : req.investisseurId;
// ── Investissements : mêmes formules que le KPI "Capital investi" / "Capital
// en risque" de l'app interne (Dashboard.jsx → capitalDeploye = encours +
// en_defaut). "capital_investi" et "capital_en_risque" sont des soldes
// nettés (montant souscrit + réinvestissements capital déjà remboursé sur
// CET investissement), pas une simple somme de montant_investi par statut —
// ça gère correctement les prêts amortissables partiellement remboursés.
const investissements = db.prepare(`
SELECT
COUNT(*) AS nb_investissements,
COALESCE(SUM(i.montant_investi), 0) AS total_investi,
COALESCE(SUM(CASE WHEN i.statut IN ('en_cours','en_retard','procedure') THEN
i.montant_investi
+ COALESCE((SELECT SUM(rv.montant) FROM reinvestissements rv WHERE rv.investissement_id = i.id), 0)
- COALESCE((SELECT SUM(rb.capital) FROM remboursements rb WHERE rb.investissement_id = i.id AND rb.type = 'normal'), 0)
END), 0) AS capital_investi,
COALESCE(SUM(CASE WHEN i.statut IN ('en_retard','procedure') THEN
i.montant_investi
+ COALESCE((SELECT SUM(rv.montant) FROM reinvestissements rv WHERE rv.investissement_id = i.id), 0)
- COALESCE((SELECT SUM(rb.capital) FROM remboursements rb WHERE rb.investissement_id = i.id AND rb.type = 'normal'), 0)
END), 0) AS capital_en_risque,
COALESCE(SUM(CASE WHEN i.statut='rembourse' THEN i.montant_investi END), 0) AS rembourse
FROM investissements i WHERE ${invCond('i.investisseur_id')}
`).get(invParam);
const interetsConds = [invCond('i.investisseur_id')];
const interetsParams = [invParam];
if (annee) { interetsConds.push(`strftime('%Y', r.date_remb) = ?`); interetsParams.push(String(annee)); }
const interets = db.prepare(`
SELECT
COALESCE(SUM(r.interets_bruts), 0) AS interets_bruts,
COALESCE(SUM(r.interets_nets), 0) AS interets_nets,
COALESCE(SUM(r.capital), 0) AS capital_recu,
COALESCE(SUM(r.net_recu), 0) AS net_recu_total
FROM remboursements r
JOIN investissements i ON i.id = r.investissement_id
WHERE ${interetsConds.join(' AND ')}
`).get(...interetsParams);
const cash = db.prepare(`
SELECT
COALESCE(SUM(CASE WHEN type='depot' THEN montant END), 0) AS total_depots,
COALESCE(SUM(CASE WHEN type='retrait' THEN montant END), 0) AS total_retraits
FROM depots_retraits WHERE ${invCond('investisseur_id')}
`).get(invParam);
res.json({ investissements, interets: { ...interets, annee: annee || null }, cash });
});
export default router;
+35
View File
@@ -0,0 +1,35 @@
import { Router } from 'express';
import db from '../../db/index.js';
const router = Router();
/**
* @openapi
* /depots-retraits:
* get:
* summary: Liste des mouvements de cash (dépôts / retraits)
* tags: [Dépôts / Retraits]
* security: [{ ApiKeyAuth: [] }]
* responses:
* 200: { description: Liste des mouvements }
*/
router.get('/', (req, res) => {
// Clé "Famille et entreprises" (scope_all) → tous les investisseurs du
// foyer ; clé mono-investisseur → filtre sur req.investisseurId.
const invCond = req.investisseurScopeAll
? 'dr.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
: 'dr.investisseur_id = ?';
const invParam = req.investisseurScopeAll ? req.userId : req.investisseurId;
const rows = db.prepare(`
SELECT dr.id, dr.date_operation, p.nom AS plateforme_nom, dr.type,
dr.montant, dr.libelle
FROM depots_retraits dr
JOIN plateformes p ON p.id = dr.plateforme_id
WHERE ${invCond}
ORDER BY dr.date_operation DESC
`).all(invParam);
res.json(rows);
});
export default router;
+16
View File
@@ -0,0 +1,16 @@
import { Router } from 'express';
import investisseurRouter from './investisseur.js';
import investissementsRouter from './investissements.js';
import remboursementsRouter from './remboursements.js';
import depotsRetraitsRouter from './depotsRetraits.js';
import dashboardRouter from './dashboard.js';
const router = Router();
router.use('/investisseur', investisseurRouter);
router.use('/investissements', investissementsRouter);
router.use('/remboursements', remboursementsRouter);
router.use('/depots-retraits', depotsRetraitsRouter);
router.use('/dashboard', dashboardRouter);
export default router;
+91
View File
@@ -0,0 +1,91 @@
import { Router } from 'express';
import db from '../../db/index.js';
import { HttpError } from '../../middleware/errorHandler.js';
const router = Router();
const LIST_COLUMNS = `
i.id, i.nom_projet, i.emetteur, p.nom AS plateforme_nom,
i.date_souscription, i.date_premiere_echeance, i.date_cible,
i.montant_investi, i.taux_interet, i.duree_mois,
i.type_remb, i.freq_interets, i.statut, i.reference
`;
/**
* @openapi
* /investissements:
* get:
* summary: Liste des investissements de l'investisseur
* tags: [Investissements]
* security: [{ ApiKeyAuth: [] }]
* parameters:
* - in: query
* name: statut
* schema: { type: string, enum: [en_cours, rembourse, en_retard, procedure, cloture] }
* responses:
* 200: { description: Liste des investissements }
*/
router.get('/', (req, res) => {
const { statut } = req.query;
// Clé "Famille et entreprises" (scope_all) → tous les investisseurs du
// foyer ; clé mono-investisseur → filtre sur req.investisseurId.
const conds = [req.investisseurScopeAll
? 'i.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
: 'i.investisseur_id = ?'];
const args = [req.investisseurScopeAll ? req.userId : req.investisseurId];
if (statut) { conds.push('i.statut = ?'); args.push(statut); }
const rows = db.prepare(`
SELECT ${LIST_COLUMNS}
FROM investissements i
JOIN plateformes p ON p.id = i.plateforme_id
WHERE ${conds.join(' AND ')}
ORDER BY i.date_souscription DESC
`).all(...args);
res.json(rows);
});
/**
* @openapi
* /investissements/{id}:
* get:
* summary: Détail d'un investissement, avec ses remboursements réels
* tags: [Investissements]
* security: [{ ApiKeyAuth: [] }]
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* responses:
* 200: { description: Détail de l'investissement }
* 404: { description: Investissement introuvable }
*/
router.get('/:id', (req, res, next) => {
try {
const invCond = req.investisseurScopeAll
? 'i.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
: 'i.investisseur_id = ?';
const invParam = req.investisseurScopeAll ? req.userId : req.investisseurId;
const inv = db.prepare(`
SELECT ${LIST_COLUMNS}, i.notes
FROM investissements i
JOIN plateformes p ON p.id = i.plateforme_id
WHERE i.id = ? AND ${invCond}
`).get(req.params.id, invParam);
if (!inv) throw new HttpError(404, 'Investissement introuvable');
const remboursements = db.prepare(`
SELECT id, date_remb, capital, interets_bruts, prelev_sociaux,
prelev_forfaitaire, cashback, interets_nets, net_recu, statut
FROM remboursements
WHERE investissement_id = ?
ORDER BY date_remb
`).all(inv.id);
res.json({ ...inv, remboursements });
} catch (e) { next(e); }
});
export default router;
+41
View File
@@ -0,0 +1,41 @@
import { Router } from 'express';
import db from '../../db/index.js';
const router = Router();
/**
* @openapi
* /investisseur:
* get:
* summary: Profil investisseur (ou liste, pour une clé "Famille et entreprises")
* description: >
* Avec une clé scopée à un seul investisseur, renvoie son profil (objet).
* Avec une clé "Famille et entreprises" (scope_all), renvoie la liste des
* investisseurs du foyer (tableau) il n'y a plus un profil unique à
* renvoyer.
* tags: [Investisseur]
* security: [{ ApiKeyAuth: [] }]
* responses:
* 200:
* description: Profil investisseur, ou liste de profils si scope_all
* 401:
* description: Clé API invalide ou manquante
*/
router.get('/', (req, res) => {
if (req.investisseurScopeAll) {
const investisseurs = db.prepare(`
SELECT id, nom, prenom, type, type_fiscal, notes, created_at
FROM investisseurs WHERE user_id = ?
ORDER BY is_principal DESC, id ASC
`).all(req.userId);
return res.json(investisseurs);
}
const inv = db.prepare(`
SELECT id, nom, prenom, type, type_fiscal, notes, created_at
FROM investisseurs WHERE id = ?
`).get(req.investisseurId);
res.json(inv);
});
export default router;
+47
View File
@@ -0,0 +1,47 @@
import { Router } from 'express';
import db from '../../db/index.js';
const router = Router();
/**
* @openapi
* /remboursements:
* get:
* summary: Liste des remboursements réels perçus
* tags: [Remboursements]
* security: [{ ApiKeyAuth: [] }]
* parameters:
* - in: query
* name: date_debut
* schema: { type: string, format: date }
* - in: query
* name: date_fin
* schema: { type: string, format: date }
* responses:
* 200: { description: Liste des remboursements }
*/
router.get('/', (req, res) => {
const { date_debut, date_fin } = req.query;
// Clé "Famille et entreprises" (scope_all) → tous les investisseurs du
// foyer ; clé mono-investisseur → filtre sur req.investisseurId.
const conds = [req.investisseurScopeAll
? 'i.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
: 'i.investisseur_id = ?'];
const args = [req.investisseurScopeAll ? req.userId : req.investisseurId];
if (date_debut) { conds.push('r.date_remb >= ?'); args.push(date_debut); }
if (date_fin) { conds.push('r.date_remb <= ?'); args.push(date_fin); }
const rows = db.prepare(`
SELECT r.id, r.date_remb, i.nom_projet, p.nom AS plateforme_nom,
r.capital, r.interets_bruts, r.prelev_sociaux, r.prelev_forfaitaire,
r.cashback, r.interets_nets, r.net_recu, r.statut
FROM remboursements r
JOIN investissements i ON i.id = r.investissement_id
JOIN plateformes p ON p.id = i.plateforme_id
WHERE ${conds.join(' AND ')}
ORDER BY r.date_remb DESC
`).all(...args);
res.json(rows);
});
export default router;
+25 -3
View File
@@ -24,6 +24,7 @@ import importsRouter from './routes/imports.js';
import pfuRouter from './routes/pfu.js';
import notationRouter from './routes/notation.js';
import garantiesRouter from './routes/garanties.js';
import objectifsRouter from './routes/objectifs.js';
import reinvestissementsRouter from './routes/reinvestissements.js';
import correctionsRouter from './routes/corrections.js';
import comptesRouter from './routes/comptes.js';
@@ -35,6 +36,7 @@ import { startAutoStatutJob } from './jobs/autoStatut.js';
import { startAutoExportJob } from './jobs/autoExport.js';
import { startAutoTicketStatusJob } from './jobs/autoTicketStatus.js';
import { startAutoCleanNotifsJob } from './jobs/autoCleanNotifs.js';
import { startCheckDonneesIncompletesJob } from './jobs/checkDonneesIncompletes.js';
import adminRouter from './routes/admin.js';
import invitationsRouter from './routes/invitations.js';
import auditLogsRouter from './routes/auditLogs.js';
@@ -50,6 +52,10 @@ import secteursInvRouter from './routes/secteurs-inv.js';
import associationsInvRouter from './routes/associations-inv.js';
import notificationsRouter from './routes/notifications.js';
import ticketsRouter from './routes/tickets.js';
import apiKeysRouter from './routes/apiKeys.js';
import v1Router from './routes/v1/index.js';
import { requireApiKey } from './middleware/apiKey.js';
import { swaggerSpec, swaggerUi } from './swagger.js';
import db from './db/index.js';
import { getSmtpConfig } from './utils/mailer.js';
@@ -71,9 +77,12 @@ const iconsDir2 = path.join(_dataDir, 'icons');
app.use('/api/icons-files', express.static(iconsDir2, { maxAge: '1h', fallthrough: false }));
// Basic rate-limit on auth endpoints
// Seuil relevé (50 → 300) : usage local mono-utilisateur/multi-investisseur avec
// beaucoup d'allers-retours de test (login, 2FA, création de comptes admin/invitation…),
// l'ancien seuil de 50 req/15min était atteint trop facilement en usage normal.
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 50,
max: 300,
standardHeaders: true,
legacyHeaders: false,
});
@@ -85,20 +94,30 @@ app.get('/api/app-info', (_, res) => {
try {
const cfg = getSmtpConfig();
const icon = db.prepare(`SELECT filename FROM app_icons WHERE name = 'logo-app' LIMIT 1`).get();
const row = db.prepare('SELECT allow_registration, min_password_length FROM smtp_config WHERE id = 1').get();
const row = db.prepare('SELECT allow_registration, min_password_length, mcp_url FROM smtp_config WHERE id = 1').get();
res.json({
appName: cfg.appName || 'Crowdlending Tracker',
iconUrl: icon ? `/api/icons-files/${icon.filename}` : null,
allowRegistration: row ? row.allow_registration !== 0 : true,
minPasswordLength: row ? (row.min_password_length || 8) : 8,
mcpUrl: row ? (row.mcp_url || '') : '',
});
} catch {
res.json({ appName: 'Crowdlending Tracker', iconUrl: null, allowRegistration: true, minPasswordLength: 8 });
res.json({ appName: 'Crowdlending Tracker', iconUrl: null, allowRegistration: true, minPasswordLength: 8, mcpUrl: '' });
}
});
app.use('/api/auth', authLimiter, authRouter);
// ── API publique v1 (lecture seule, authentification par clé API) ────────
// Montée ici, avant les routes internes ci-dessous : la ligne
// `app.use('/api', requireAuth, associationsInvRouter)` plus bas capte tout
// préfixe /api/* avec le JWT interne, ce qui intercepterait /api/v1/* et
// /api/docs si elles étaient montées après.
app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
app.get('/api/openapi.json', (_, res) => res.json(swaggerSpec));
app.use('/api/v1', requireApiKey, v1Router);
// All routes below require authentication
app.use('/api/investisseurs', requireAuth, investisseursRouter);
app.use('/api/plateformes', requireAuth, plateformesRouter);
@@ -114,6 +133,7 @@ app.use('/api/imports', requireAuth, importsRouter);
app.use('/api/pfu', requireAuth, pfuRouter);
app.use('/api/notation', requireAuth, notationRouter);
app.use('/api/garanties', requireAuth, garantiesRouter);
app.use('/api/objectifs', requireAuth, objectifsRouter);
app.use('/api/reinvestissements', requireAuth, reinvestissementsRouter);
app.use('/api/corrections', requireAuth, correctionsRouter);
app.use('/api/comptes', requireAuth, comptesRouter);
@@ -136,6 +156,7 @@ app.use('/api/secteurs-inv', requireAuth, secteursInvRouter);
app.use('/api', requireAuth, associationsInvRouter);
app.use('/api/notifications', notificationsRouter);
app.use('/api/tickets', requireAuth, ticketsRouter);
app.use('/api/api-keys', requireAuth, apiKeysRouter);
app.use(errorHandler);
@@ -145,4 +166,5 @@ app.listen(PORT, () => {
startAutoExportJob();
startAutoTicketStatusJob();
startAutoCleanNotifsJob();
startCheckDonneesIncompletesJob();
});
+46
View File
@@ -0,0 +1,46 @@
import swaggerJsdoc from 'swagger-jsdoc';
import swaggerUi from 'swagger-ui-express';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// swagger-jsdoc résout ses motifs `apis` avec `glob`, qui n'interprète pas
// les antislashs Windows comme séparateurs — on force donc des slashs.
const toGlobPath = (p) => p.split(path.sep).join('/');
/**
* Documentation OpenAPI de l'API publique v1 (lecture seule).
* Générée depuis les annotations JSDoc `@openapi` dans backend/src/routes/v1/*.js.
* Servie sur /api/docs (public, pas d'authentification pour consulter la doc
* seules les requêtes réelles vers /api/v1/... nécessitent une clé API).
*/
const swaggerSpec = swaggerJsdoc({
definition: {
openapi: '3.0.3',
info: {
title: 'Crowdlending Tracker API',
version: 'v1',
description:
"API publique en lecture seule du portefeuille de crowdlending. " +
"Authentification par clé API (header `X-API-Key`), générée depuis Mon compte → Clés API. " +
"Chaque clé est scopée à un seul investisseur.",
},
servers: [{ url: '/api/v1' }],
components: {
securitySchemes: {
ApiKeyAuth: {
type: 'apiKey',
in: 'header',
name: 'X-API-Key',
},
},
},
security: [{ ApiKeyAuth: [] }],
},
apis: [toGlobPath(path.join(__dirname, 'routes/v1/*.js'))],
});
const nbPaths = Object.keys(swaggerSpec.paths || {}).length;
console.log(`[Swagger] ${nbPaths} route(s) documentée(s) sur /api/docs`);
export { swaggerSpec, swaggerUi };
+57 -12
View File
@@ -106,7 +106,7 @@ export function buildSchedule({ montant, taux, duree, type, freq, startDate, fin
}
/** Nombre de mois entiers entre deux dates ISO */
function monthsDiff(isoA, isoB) {
export function monthsDiff(isoA, isoB) {
const a = new Date(isoA);
const b = new Date(isoB);
return (b.getFullYear() - a.getFullYear()) * 12 + (b.getMonth() - a.getMonth());
@@ -129,7 +129,9 @@ export function adjustSimulForActuals(db, investissementId) {
FROM investissements WHERE id = ?
`).get(investissementId);
if (!inv || !inv.taux_interet || !inv.duree_mois) return;
// taux_interet peut légitimement valoir 0 (ex. révision suite à un arrêt de production
// n'engendrant plus d'intérêts) — ne pas confondre avec "non renseigné" (null/undefined).
if (!inv || inv.taux_interet == null || !inv.duree_mois) return;
// Réinvestissements triés par date (peuvent être vides)
const reinvests = db.prepare(
@@ -379,7 +381,8 @@ export function generateSimulWithReinvestissements(db, investissementId) {
return;
}
if (!inv.taux_interet || !inv.duree_mois) return;
// taux_interet peut légitimement valoir 0 (cf. commentaire dans adjustSimulForActuals)
if (inv.taux_interet == null || !inv.duree_mois) return;
const startDate = inv.date_debut_simul || inv.date_premiere_echeance || inv.date_souscription;
if (!startDate) return;
@@ -460,14 +463,55 @@ export function generateSimulWithReinvestissements(db, investissementId) {
}
db.transaction(() => {
db.prepare('DELETE FROM simul_remboursements WHERE investissement_id=?').run(investissementId);
const stmt = db.prepare(`
INSERT INTO simul_remboursements
(investissement_id, numero_echeance, date_prevue, capital_prevu, interets_prevus, total_prevu)
VALUES (?,?,?,?,?,?)
`);
for (const e of schedule) {
stmt.run(investissementId, e.n, e.date, e.capital, e.interets, e.total);
if (inv.date_debut_simul) {
// ── Mode restructuration ──────────────────────────────────────────────
// Même logique que generateSimul() : conserver les échéances déjà payées avant
// la date de restructuration, supprimer le reste, et renuméroter à partir du
// nombre de mois réellement écoulés (pas de repartir à 1, qui ferait disparaître
// les échéances passées — déjà payées — de la table des projections).
const keptEntries = db.prepare(`
SELECT sr.id, sr.numero_echeance FROM simul_remboursements sr
WHERE sr.investissement_id = ?
AND sr.date_prevue < ?
AND EXISTS (
SELECT 1 FROM remboursements r
WHERE r.investissement_id = sr.investissement_id
AND substr(r.date_remb, 1, 7) = substr(sr.date_prevue, 1, 7)
)
ORDER BY sr.numero_echeance
`).all(investissementId, inv.date_debut_simul);
if (keptEntries.length > 0) {
db.prepare(
`DELETE FROM simul_remboursements WHERE investissement_id = ? AND id NOT IN (${keptEntries.map(() => '?').join(',')})`
).run(investissementId, ...keptEntries.map(e => e.id));
} else {
db.prepare('DELETE FROM simul_remboursements WHERE investissement_id=?').run(investissementId);
}
const elapsedMonths = (inv.date_premiere_echeance && inv.date_debut_simul > inv.date_premiere_echeance)
? monthsDiff(inv.date_premiere_echeance, inv.date_debut_simul)
: keptEntries.length;
const stmt = db.prepare(`
INSERT INTO simul_remboursements
(investissement_id, numero_echeance, date_prevue, capital_prevu, interets_prevus, total_prevu)
VALUES (?,?,?,?,?,?)
`);
for (const e of schedule) {
stmt.run(investissementId, elapsedMonths + e.n, e.date, e.capital, e.interets, e.total);
}
} else {
// ── Mode standard : régénération complète ────────────────────────────
db.prepare('DELETE FROM simul_remboursements WHERE investissement_id=?').run(investissementId);
const stmt = db.prepare(`
INSERT INTO simul_remboursements
(investissement_id, numero_echeance, date_prevue, capital_prevu, interets_prevus, total_prevu)
VALUES (?,?,?,?,?,?)
`);
for (const e of schedule) {
stmt.run(investissementId, e.n, e.date, e.capital, e.interets, e.total);
}
}
})();
}
@@ -484,7 +528,8 @@ export function generateSimul(db, inv) {
const { id, montant_investi, taux_interet, duree_mois, type_remb, freq_interets,
date_premiere_echeance, date_debut_simul, date_souscription, echeance_fin_de_mois } = inv;
if (!taux_interet || !duree_mois) return;
// taux_interet peut légitimement valoir 0 (cf. commentaire dans adjustSimulForActuals)
if (taux_interet == null || !duree_mois) return;
// date_debut_simul remplace le point de départ quand le prêt a été restructuré
const startDate = date_debut_simul || date_premiere_echeance || date_souscription;
+37 -1
View File
@@ -48,7 +48,43 @@ services:
networks:
- internal
- backend # réseau Traefik
# Serveur MCP (Phase 5) — même image/code que le serveur utilisé en
# développement local (mcp-server/server.js, npm run dev), déployé ici
# accessible publiquement sur un sous-domaine dédié, SANS le middleware
# ipwhitelist-all : contrairement au reste de l'app, ce service est
# volontairement ouvert à des utilisateurs distants qui ne peuvent pas
# faire tourner le serveur en local. La clé API (en-tête X-API-Key, propre
# à chaque utilisateur) est donc la SEULE barrière d'accès.
crowdlending-mcp:
build:
context: ./mcp-server
dockerfile: Dockerfile
container_name: crowdlending-mcp
restart: unless-stopped
environment:
NODE_ENV: production
PORT: 4100
CROWDLENDING_API_URL: http://crowdlending-backend:4000/api/v1
MCP_ALLOWED_HOSTS: mcp.crowdlending.croguennec.net,localhost
MCP_LABEL: prod
# Explicitement désactivé : ce serveur sert des utilisateurs distants
# non maîtrisés, l'outil de lecture d'URL arbitraire (SSRF) reste
# réservé au développement local. Ne pas passer à true ici.
MCP_ENABLE_FETCH_URL: "false"
depends_on:
crowdlending-backend:
condition: service_healthy
labels:
- "traefik.enable=true"
- "traefik.http.routers.crowdlending-mcp.rule=Host(`mcp.crowdlending.croguennec.net`)"
- "traefik.http.routers.crowdlending-mcp.entrypoints=websecure"
- "traefik.http.routers.crowdlending-mcp.tls.certresolver=le"
- "traefik.http.services.crowdlending-mcp.loadbalancer.server.port=4100"
networks:
- internal
- backend # réseau Traefik
networks:
internal: # communication interne backend <-> frontend
driver: bridge
+13 -1
View File
@@ -23,8 +23,20 @@ server {
client_max_body_size 15M;
}
# SPA fallback
# site.webmanifest jamais mis en cache (sinon iOS garde l'ancienne
# config "Add to Home Screen" après un déploiement)
location = /site.webmanifest {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
}
# SPA fallback index.html ne doit JAMAIS être mis en cache : la webapp
# installée sur l'écran d'accueil iPad (mode standalone) n'a ni bouton
# reload ni pull-to-refresh, donc si index.html reste en cache le
# WKWebView continue de charger l'ancien bundle après un déploiement.
location / {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
try_files $uri $uri/ /index.html;
}
}
+241
View File
@@ -8,6 +8,7 @@
"name": "crowdlending-frontend",
"version": "0.1.0",
"dependencies": {
"html2pdf.js": "^0.14.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2",
@@ -252,6 +253,15 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -1159,6 +1169,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/pako": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
"license": "MIT"
},
"node_modules/@types/raf": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -1189,6 +1219,15 @@
"node": ">=0.8"
}
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.25",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.25.tgz",
@@ -1257,6 +1296,26 @@
],
"license": "CC-BY-4.0"
},
"node_modules/canvg": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
"license": "MIT",
"optional": true,
"dependencies": {
"@babel/runtime": "^7.12.5",
"@types/raf": "^3.4.0",
"core-js": "^3.8.3",
"raf": "^3.4.1",
"regenerator-runtime": "^0.13.7",
"rgbcolor": "^1.0.1",
"stackblur-canvas": "^2.0.0",
"svg-pathdata": "^6.0.3"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
@@ -1286,6 +1345,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/core-js": {
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
@@ -1298,6 +1369,15 @@
"node": ">=0.8"
}
},
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1316,6 +1396,15 @@
}
}
},
"node_modules/dompurify": {
"version": "3.4.12",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz",
"integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.349",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz",
@@ -1372,6 +1461,23 @@
"node": ">=6"
}
},
"node_modules/fast-png": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
"license": "MIT",
"dependencies": {
"@types/pako": "^2.0.3",
"iobuffer": "^5.3.2",
"pako": "^2.1.0"
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
@@ -1406,6 +1512,36 @@
"node": ">=6.9.0"
}
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/html2pdf.js": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/html2pdf.js/-/html2pdf.js-0.14.0.tgz",
"integrity": "sha512-yvNJgE/8yru2UeGflkPdjW8YEY+nDH5X7/2WG4uiuSCwYiCp8PZ8EKNiTAa6HxJ1NjC51fZSIEq6xld5CADKBQ==",
"license": "MIT",
"dependencies": {
"dompurify": "^3.3.1",
"html2canvas": "^1.0.0",
"jspdf": "^4.0.0"
}
},
"node_modules/iobuffer": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
"license": "MIT"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1438,6 +1574,23 @@
"node": ">=6"
}
},
"node_modules/jspdf": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz",
"integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.6",
"fast-png": "^6.2.0",
"fflate": "^0.8.1"
},
"optionalDependencies": {
"canvg": "^3.0.11",
"core-js": "^3.6.0",
"dompurify": "^3.3.1",
"html2canvas": "^1.0.0-rc.5"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -1493,6 +1646,29 @@
"dev": true,
"license": "MIT"
},
"node_modules/pako": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz",
"integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "(MIT AND Zlib)"
},
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
"license": "MIT",
"optional": true
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1529,6 +1705,16 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/raf": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
"license": "MIT",
"optional": true,
"dependencies": {
"performance-now": "^2.1.0"
}
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -1596,6 +1782,23 @@
"react-dom": ">=16.8"
}
},
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT",
"optional": true
},
"node_modules/rgbcolor": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
"optional": true,
"engines": {
"node": ">= 0.8.15"
}
},
"node_modules/rollup": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz",
@@ -1682,6 +1885,35 @@
"node": ">=0.8"
}
},
"node_modules/stackblur-canvas": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.1.14"
}
},
"node_modules/svg-pathdata": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@@ -1713,6 +1945,15 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+1
View File
@@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"html2pdf.js": "^0.14.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2",
Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

+112
View File
@@ -0,0 +1,112 @@
# Power Query — exemples de connexion à l'API v1
Exemples de requêtes [Power Query](https://learn.microsoft.com/fr-fr/power-query/) (Excel)
pour interroger le portefeuille crowdlending depuis un classeur Excel, en s'appuyant sur
la même API v1 (lecture seule) que le [serveur MCP](../mcp-server/README.md).
Aucune écriture : ces requêtes ne font que lire des données, comme le serveur MCP.
## Prérequis
- Une clé API générée dans l'app : **Mon compte → Clés API → Nouvelle clé**
- Le backend accessible (en local `http://localhost:4000`, ou l'URL de votre instance en
production, ex. `https://crowdlending.croguennec.net`)
- Excel avec Power Query (Excel 365 / 2016+ sous Windows ou Mac — Power Query est intégré
nativement, rien à installer)
## Option 1 — Classeur prêt à l'emploi (`crowdlending-powerquery-exemple.xlsb`)
Le plus rapide : ouvrez `crowdlending-powerquery-exemple.xlsb` directement dans Excel.
> **Pourquoi un `.xlsb` et pas un `.xlsx` ?** Le format binaire d'Excel (`.xlsb`) est celui
> à partir duquel ce classeur a pu être construit et vérifié de façon fiable en dehors
> d'Excel. Il s'ouvre et se comporte exactement comme un `.xlsx` — Power Query, tableaux,
> actualisation, tout fonctionne à l'identique. Si vous préférez un `.xlsx`, ouvrez le
> fichier puis **Fichier → Enregistrer sous** et changez le format ; les requêtes suivent.
Le classeur contient :
| Requête | Rôle |
|---|---|
| `ApiBaseUrl` | URL de l'API à interroger (à modifier) |
| `ApiKey` | Votre clé API (à modifier — voir ci-dessous) |
| `fnApiGet` | Fonction utilitaire partagée (appel HTTP + parsing JSON), utilisée par toutes les requêtes de données |
| `Investisseur` | Profil investisseur (ou liste des membres si clé « Famille et entreprises ») |
| `Dashboard` | KPIs du portefeuille (capital investi, capital en risque, intérêts, cash), au format « Indicateur / Valeur » |
| `Investissements` | Liste des investissements |
| `Remboursements` | Historique des remboursements |
| `DepotsRetraits` | Historique des mouvements de cash |
| `fnDetailInvestissement` | Fonction avancée : détail + remboursements d'un investissement par id |
| `Test` | Table d'instructions (« À lire avant de commencer ») — pas une donnée métier |
Étapes :
1. Ouvrez le classeur. La feuille affiche par défaut un exemple mis en cache (pas encore
vos données) — c'est normal, Excel n'a pas encore appelé l'API.
2. **Données → Requêtes et connexions**. Repérez `ApiKey` dans le volet à droite.
3. Clic droit sur `ApiKey`**Modifier**. Dans l'éditeur, remplacez
`"clk_live_VOTRE_CLE_API"` par votre vraie clé (entre guillemets), puis
**Fermer et charger**.
4. Vérifiez `ApiBaseUrl` de la même façon : `http://localhost:4000/api/v1` en local, ou
`https://crowdlending.croguennec.net/api/v1` en production (adaptez à votre domaine).
5. **Données → Actualiser tout** (ou Ctrl+Alt+F5). La table « Test » se met à jour avec les
instructions, signe que la connexion fonctionne.
6. Pour chaque requête de données qui vous intéresse (`Investissements`, `Remboursements`…) :
clic droit dans le volet **Requêtes et connexions****Charger dans…** → choisissez
Tableau (ou Tableau croisé dynamique) et la feuille de destination.
## Option 2 — Coller les requêtes manuellement (`queries/*.pq`)
Utile si vous préférez tout construire vous-même dans un classeur existant, ou si une
requête du classeur ne s'affiche pas correctement chez vous.
Pour chaque fichier, dans l'ordre ci-dessous : **Données → Obtenir des données → À partir
d'autres sources → Requête vide**, renommez la requête (volet de droite, ou après double-clic
sur son nom) avec **exactement** le nom du fichier (sans `.pq`), puis **Accueil → Éditeur
avancé**, effacez le contenu par défaut, collez le contenu du fichier, **Terminé**.
Ordre à respecter (chaque requête réutilise les précédentes par leur nom) :
1. `ApiBaseUrl.pq` — modifiez l'URL avant de coller si besoin
2. `ApiKey.pq` — remplacez `VOTRE_CLE_API` par votre clé avant de coller
3. `fnApiGet.pq`
4. `Investisseur.pq`, `Dashboard.pq`, `Investissements.pq`, `Remboursements.pq`,
`DepotsRetraits.pq` — dans l'ordre que vous voulez
5. `fnDetailInvestissement.pq` (optionnel, usage avancé — voir plus bas)
Une fois `fnApiGet` créée, **Fermer et charger** chaque requête de données individuellement
(clic droit → Charger dans…) pour l'ajouter comme tableau.
## Filtres
Les requêtes `Dashboard`, `Investissements` et `Remboursements` acceptent des filtres côté
API (année, statut, période). Par défaut elles ramènent tout (`null`). Pour filtrer,
ouvrez la requête dans l'éditeur avancé et remplacez `null` par le paramètre indiqué en
commentaire en tête du fichier `.pq` correspondant, par exemple :
```
Source = fnApiGet("/investissements", [statut = "rembourse"])
```
Statuts possibles : `en_cours`, `rembourse`, `en_retard`, `procedure`, `cloture`.
## Usage avancé — détail d'un investissement par ligne
`fnDetailInvestissement` permet de ramener, pour chaque ligne de la table `Investissements`,
le détail complet (dont les remboursements) sans requête séparée : sur la table
`Investissements`, **Ajout de colonne → Colonne personnalisée**, formule
`= fnDetailInvestissement([id])`. Attention : ceci déclenche un appel API par ligne — à
réserver à un nombre raisonnable d'investissements (quelques dizaines).
## Dépannage
- **`Impossible de se connecter au service distant`** — le backend n'est pas démarré, ou
`ApiBaseUrl` est incorrecte (vérifiez le port et le suffixe `/api/v1`).
- **`Erreur API (401)` / `Clé API invalide ou révoquée`** — régénérez une clé dans
Mon compte → Clés API et remplacez la valeur de `ApiKey`.
- **Une requête du classeur n'apparaît pas dans le volet, ou affiche une erreur au premier
chargement** — repartez de l'Option 2 pour cette requête précise : créez une requête
vide portant son nom et collez le contenu du `.pq` correspondant.
- **Avertissement de sécurité / niveau de confidentialité au premier chargement** — normal
pour toute nouvelle source Web dans Power Query ; choisissez « Organisationnel » ou
« Public » selon votre contexte, ce n'est pas spécifique à ce classeur.
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

@@ -0,0 +1,3 @@
// URL de base de l'API v1. En local : http://localhost:4000/api/v1
// En production : https://votre-domaine/api/v1
"http://localhost:4000/api/v1"
@@ -0,0 +1,2 @@
// Clé API générée dans l'app : Mon compte > Clés API > Nouvelle clé
"clk_live_VOTRE_CLE_API"
@@ -0,0 +1,14 @@
let
// Pour filtrer sur une année, remplacez null par [annee = "2026"]
Source = fnApiGet("/dashboard", null),
VersLignes = (enregistrement as record, nomSection as text) =>
Table.AddColumn(Record.ToTable(enregistrement), "Section", each nomSection),
Combine = Table.Combine({
VersLignes(Source[investissements], "Investissements"),
VersLignes(Source[interets], "Interets"),
VersLignes(Source[cash], "Cash")
}),
Reordonne = Table.ReorderColumns(Combine, {"Section", "Name", "Value"}),
Resultat = Table.RenameColumns(Reordonne, {{"Name", "Indicateur"}, {"Value", "Valeur"}})
in
Resultat
@@ -0,0 +1,5 @@
let
Source = fnApiGet("/depots-retraits", null),
Resultat = Table.FromRecords(Source)
in
Resultat
@@ -0,0 +1,7 @@
let
// Pour filtrer par statut, remplacez null par [statut = "rembourse"]
// Statuts possibles : en_cours, rembourse, en_retard, procedure, cloture
Source = fnApiGet("/investissements", null),
Resultat = Table.FromRecords(Source)
in
Resultat
@@ -0,0 +1,6 @@
let
Source = fnApiGet("/investisseur", null),
// Clé "Famille et entreprises" -> liste ; clé mono-investisseur -> objet unique
Resultat = if Value.Is(Source, type list) then Table.FromRecords(Source) else Table.FromRecords({Source})
in
Resultat
@@ -0,0 +1,6 @@
let
// Pour filtrer par période, remplacez null par [date_debut = "2026-01-01", date_fin = "2026-12-31"]
Source = fnApiGet("/remboursements", null),
Resultat = Table.FromRecords(Source)
in
Resultat
@@ -0,0 +1,19 @@
(chemin as text, optional parametres as nullable record) as any =>
let
parametresBruts = if parametres = null then [] else parametres,
champsUtiles = List.Select(
Record.FieldNames(parametresBruts),
each Record.Field(parametresBruts, _) <> null and Record.Field(parametresBruts, _) <> ""
),
parametresNettoyes = Record.SelectFields(parametresBruts, champsUtiles),
reponse = Web.Contents(
ApiBaseUrl,
[
RelativePath = chemin,
Headers = [#"X-API-Key" = ApiKey, #"Accept" = "application/json"],
Query = parametresNettoyes
]
),
resultat = Json.Document(reponse)
in
resultat
@@ -0,0 +1,5 @@
// Astuce avancée : sur la table Investissements, colonne personnalisée
// "= fnDetailInvestissement([id])" pour ramener le détail + les remboursements
// de chaque prêt (Ajout de colonne > Colonne personnalisée).
(id as number) as record =>
fnApiGet("/investissements/" & Text.From(id), null)
+20
View File
@@ -49,6 +49,26 @@ export default function App() {
}).catch(() => {});
}, []);
// Webapp installée sur l'écran d'accueil (iPad) : mode standalone, donc pas
// de bouton reload ni de pull-to-refresh natif. Si l'app est restée en
// arrière-plan un moment (cas typique : on la rouvre depuis l'icône des
// heures/jours après un déploiement serveur), on force un reload complet
// au retour au premier plan pour repartir sur un bundle et des données à jour.
useEffect(() => {
let hiddenAt = null;
const onVisibility = () => {
if (document.visibilityState === 'hidden') {
hiddenAt = Date.now();
} else if (document.visibilityState === 'visible' && hiddenAt) {
const awayMs = Date.now() - hiddenAt;
hiddenAt = null;
if (awayMs > 2 * 60 * 1000) window.location.reload();
}
};
document.addEventListener('visibilitychange', onVisibility);
return () => document.removeEventListener('visibilitychange', onVisibility);
}, []);
return (
<Routes>
<Route path="/login" element={<Login />} />
+8 -4
View File
@@ -34,7 +34,7 @@ export const api = {
const qs = params ? '?' + new URLSearchParams(
Object.entries(params).filter(([, v]) => v !== undefined && v !== null && v !== '')
).toString() : '';
return fetch(BASE + path + qs, { headers: authHeaders() }).then(handle);
return fetch(BASE + path + qs, { headers: authHeaders(), cache: 'no-store' }).then(handle);
},
post: (path, body) =>
fetch(BASE + path, {
@@ -54,14 +54,18 @@ export const api = {
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(body),
}).then(handle),
del: (path) =>
fetch(BASE + path, { method: 'DELETE', headers: authHeaders() }).then(handle),
del: (path, body) =>
fetch(BASE + path, {
method: 'DELETE',
headers: body ? { 'Content-Type': 'application/json', ...authHeaders() } : authHeaders(),
...(body ? { body: JSON.stringify(body) } : {}),
}).then(handle),
upload: (path, formData) =>
fetch(BASE + path, { method: 'POST', body: formData, headers: authHeaders() }).then(handle),
postForm: (path, formData) =>
fetch(BASE + path, { method: 'POST', body: formData, headers: authHeaders() }).then(handle),
blob: (path) =>
fetch(BASE + path, { headers: authHeaders() }).then(async res => {
fetch(BASE + path, { headers: authHeaders(), cache: 'no-store' }).then(async res => {
if (!res.ok) { const t = await res.text(); throw new Error(t || res.statusText); }
return res.blob();
}),
+1 -1
View File
@@ -14,7 +14,7 @@ export default function EmptyState({
title = 'Aucune plateforme configurée',
message = "Vous n'avez pas encore configuré de plateforme de crowdfunding sur votre compte. Associez au moins une plateforme pour commencer à suivre vos investissements.",
cta = 'Configurer mes plateformes',
to = '/settings?section=plateformes',
to = '/settings?section=plateformes&openAdd=1',
}) {
const navigate = useNavigate();
+48
View File
@@ -0,0 +1,48 @@
import { useState } from 'react';
/**
* Champ mot de passe avec bouton "afficher/masquer".
* Utilisation : remplacer <input type="password" .../> par <PasswordInput .../>
* Toutes les props (className, value, onChange, required, autoComplete, style, etc.)
* sont transmises telles quelles à l'<input> sous-jacent.
*/
function EyeIcon({ open }) {
return open ? (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z" stroke="var(--text-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<circle cx="12" cy="12" r="3" stroke="var(--text-muted)" strokeWidth="2"/>
</svg>
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M17.94 17.94A10.94 10.94 0 0112 19c-7 0-11-7-11-7a20.6 20.6 0 015.06-5.94M9.9 4.24A10.9 10.9 0 0112 4c7 0 11 7 11 7a20.6 20.6 0 01-2.66 3.78M14.12 14.12a3 3 0 11-4.24-4.24" stroke="var(--text-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<line x1="1" y1="1" x2="23" y2="23" stroke="var(--text-muted)" strokeWidth="2" strokeLinecap="round"/>
</svg>
);
}
export default function PasswordInput({ style, wrapperStyle, ...props }) {
const [show, setShow] = useState(false);
return (
<div style={{ position: 'relative', ...wrapperStyle }}>
<input
{...props}
type={show ? 'text' : 'password'}
style={{ ...style, paddingRight: 40 }}
/>
<button
type="button"
tabIndex={-1}
onClick={() => setShow(v => !v)}
aria-label={show ? 'Masquer le mot de passe' : 'Afficher le mot de passe'}
style={{
position: 'absolute', right: 10, top: '50%', transform: 'translateY(-50%)',
background: 'none', border: 'none', padding: 4, display: 'flex',
alignItems: 'center', justifyContent: 'center', cursor: 'pointer', lineHeight: 0,
}}
>
<EyeIcon open={show} />
</button>
</div>
);
}
+276
View File
@@ -0,0 +1,276 @@
import { useEffect, useMemo, useState } from 'react';
import { api } from '../api.js';
import { fmtEUR, memberLabel } from '../utils/format.js';
/*
* SuiviObjectifs tableau générique de suivi d'objectifs annuels.
*
* Conçu pour être réutilisable au-delà des Dépôts/Retraits : il suffit de
* fournir des lignes de mouvements bruts (avec investisseur_id, type, montant,
* date) et la liste des investisseurs concernés. La table `objectifs`
* (backend) porte un champ `type` qui permet de stocker d'autres natures
* d'objectifs plus tard sans nouvelle table.
*
* Props :
* - rows : mouvements bruts [{ investisseur_id, type: 'depot'|'retrait', montant, date_operation }]
* - investisseurs : liste complète des investisseurs (pour affichage des noms)
* - scopeInvestisseurIds : ids des investisseurs actuellement dans le scope de vue
* (tous les investisseurs si vue "tous", sinon [activeId])
* - objectifType : clé de type stockée en base (défaut 'versement_annuel')
* - title : titre de la section
*/
export default function SuiviObjectifs({
rows = [],
investisseurs = [],
scopeInvestisseurIds = [],
objectifType = 'versement_annuel',
title = 'Suivi des objectifs de versement',
}) {
const [objectifs, setObjectifs] = useState([]);
const [loading, setLoading] = useState(false);
const [editingYear, setEditingYear] = useState(null);
const [draft, setDraft] = useState({});
const [saving, setSaving] = useState(false);
const [extraYears, setExtraYears] = useState([]);
const loadObjectifs = () => {
setLoading(true);
api.get('/objectifs', { type: objectifType })
.then(rows => setObjectifs(Array.isArray(rows) ? rows : []))
.catch(() => setObjectifs([]))
.finally(() => setLoading(false));
};
useEffect(() => { loadObjectifs(); /* eslint-disable-next-line */ }, [objectifType]);
const scopedInvestisseurs = useMemo(
() => investisseurs.filter(i => scopeInvestisseurIds.includes(i.id)),
[investisseurs, scopeInvestisseurIds]
);
/* ── Années à afficher : celles avec mouvements + celles avec objectif + année courante ── */
const years = useMemo(() => {
const set = new Set(extraYears);
for (const r of rows) {
if (scopeInvestisseurIds.includes(r.investisseur_id) && r.date_operation) {
set.add(Number(r.date_operation.slice(0, 4)));
}
}
for (const o of objectifs) {
if (scopeInvestisseurIds.includes(o.investisseur_id)) set.add(Number(o.annee));
}
set.add(new Date().getFullYear());
return [...set].sort((a, b) => b - a);
}, [rows, objectifs, scopeInvestisseurIds, extraYears]);
/* ── Totaux dépôts/retraits par année (scope courant) ── */
const totalsByYear = useMemo(() => {
const map = {};
for (const year of years) map[year] = { depots: 0, retraits: 0 };
for (const r of rows) {
if (!scopeInvestisseurIds.includes(r.investisseur_id)) continue;
const y = Number(r.date_operation?.slice(0, 4));
if (!map[y]) continue;
if (r.type === 'depot') map[y].depots += r.montant;
else if (r.type === 'retrait') map[y].retraits += r.montant;
}
return map;
}, [rows, years, scopeInvestisseurIds]);
/* ── Objectifs par année (scope courant) ── */
const objectifsByYear = useMemo(() => {
const map = {};
for (const o of objectifs) {
if (!scopeInvestisseurIds.includes(o.investisseur_id)) continue;
if (!map[o.annee]) map[o.annee] = [];
map[o.annee].push(o);
}
return map;
}, [objectifs, scopeInvestisseurIds]);
const openEditor = (year) => {
const existing = objectifsByYear[year] || [];
const d = {};
for (const inv of scopedInvestisseurs) {
const found = existing.find(o => o.investisseur_id === inv.id);
d[inv.id] = found ? String(found.montant) : '';
}
setDraft(d);
setEditingYear(year);
};
const cancelEditor = () => { setEditingYear(null); setDraft({}); };
const saveEditor = async (year) => {
setSaving(true);
try {
const existing = objectifsByYear[year] || [];
const calls = [];
for (const inv of scopedInvestisseurs) {
const raw = draft[inv.id];
const found = existing.find(o => o.investisseur_id === inv.id);
if (raw === '' || raw === undefined) {
if (found) calls.push(api.del(`/objectifs/${found.id}`));
continue;
}
const montant = Number(raw);
if (Number.isNaN(montant) || montant < 0) continue;
calls.push(api.post('/objectifs', {
investisseur_id: inv.id, type: objectifType, annee: year, montant,
}));
}
await Promise.all(calls);
loadObjectifs();
cancelEditor();
} finally { setSaving(false); }
};
const addYear = () => {
const maxYear = years.length ? Math.max(...years) : new Date().getFullYear();
setExtraYears(prev => [...prev, maxYear + 1]);
};
/* ── Ligne Total : somme des colonnes sur les années affichées ── */
const overallTotals = useMemo(() => {
let depots = 0, retraits = 0;
let objectifSum = 0, hasObjectif = false;
let ecartSum = 0, hasEcart = false;
for (const year of years) {
const t = totalsByYear[year] || { depots: 0, retraits: 0 };
depots += t.depots;
retraits += t.retraits;
const objs = objectifsByYear[year] || [];
if (objs.length > 0) {
const objectifTotal = objs.reduce((s, o) => s + o.montant, 0);
objectifSum += objectifTotal;
hasObjectif = true;
ecartSum += (t.depots - t.retraits) - objectifTotal;
hasEcart = true;
}
}
return {
depots, retraits, diff: depots - retraits,
objectifTotal: hasObjectif ? objectifSum : null,
ecart: hasEcart ? ecartSum : null,
};
}, [years, totalsByYear, objectifsByYear]);
return (
<div className="card" style={{ marginTop: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<h3 style={{ margin: 0 }}>{title}</h3>
<button type="button" className="btn-outline" onClick={addYear} style={{ fontSize: 'var(--fs-sm)' }}>
+ Ajouter une année
</button>
</div>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ textAlign: 'left', fontSize: 'var(--fs-xs)', textTransform: 'uppercase', letterSpacing: '.06em', color: 'var(--text-muted)' }}>
<th style={{ padding: '8px 10px' }}>Année</th>
<th style={{ padding: '8px 10px' }}>Dépôts</th>
<th style={{ padding: '8px 10px' }}>Retraits</th>
<th style={{ padding: '8px 10px' }}>Différence</th>
<th style={{ padding: '8px 10px' }}>Objectif annuel</th>
<th style={{ padding: '8px 10px' }}>Écart</th>
</tr>
</thead>
<tbody>
{years.map(year => {
const t = totalsByYear[year] || { depots: 0, retraits: 0 };
const diff = t.depots - t.retraits;
const objs = objectifsByYear[year] || [];
const objectifTotal = objs.length > 0 ? objs.reduce((s, o) => s + o.montant, 0) : null;
const ecart = objectifTotal != null ? diff - objectifTotal : null;
const isEditing = editingYear === year;
return (
<tr key={year} style={{ borderTop: '1px solid var(--border)' }}>
<td style={{ padding: '8px 10px', fontWeight: 600 }}>{year}</td>
<td style={{ padding: '8px 10px', color: 'var(--success)' }}>{fmtEUR(t.depots)}</td>
<td style={{ padding: '8px 10px', color: 'var(--danger)' }}>{fmtEUR(t.retraits)}</td>
<td style={{ padding: '8px 10px', fontWeight: 600 }}>{fmtEUR(diff)}</td>
<td style={{ padding: '8px 10px' }}>
{isEditing ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{scopedInvestisseurs.map(inv => (
<div key={inv.id} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{scopedInvestisseurs.length > 1 && (
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', minWidth: 90 }}>
{memberLabel(inv)}
</span>
)}
<input
type="number"
min="0"
step="1"
placeholder="—"
value={draft[inv.id] ?? ''}
onChange={e => setDraft(d => ({ ...d, [inv.id]: e.target.value }))}
style={{ width: 110, padding: '4px 6px', border: '1px solid var(--border)', borderRadius: 6, background: 'var(--surface)' }}
/>
</div>
))}
<div style={{ display: 'flex', gap: 6, marginTop: 2 }}>
<button type="button" disabled={saving} onClick={() => saveEditor(year)} style={{ fontSize: 'var(--fs-xs)' }}>
Enregistrer
</button>
<button type="button" className="ghost" disabled={saving} onClick={cancelEditor} style={{ fontSize: 'var(--fs-xs)' }}>
Annuler
</button>
</div>
</div>
) : (
<button
type="button"
className="ghost"
onClick={() => openEditor(year)}
style={{ padding: '2px 6px', fontSize: 'var(--fs-sm)', color: objectifTotal != null ? 'var(--text)' : 'var(--text-muted)' }}
title="Cliquer pour définir l'objectif"
>
{objectifTotal != null ? fmtEUR(objectifTotal) : '+ Définir'}
</button>
)}
</td>
<td style={{ padding: '8px 10px' }}>
{ecart == null ? (
<span style={{ color: 'var(--text-muted)' }}></span>
) : ecart >= 0 ? (
<span style={{ color: 'var(--success)', fontWeight: 600 }}>
+{fmtEUR(ecart)} au-delà de l'objectif
</span>
) : (
<span style={{ color: 'var(--warning)', fontWeight: 600 }}>
Reste {fmtEUR(-ecart)}
</span>
)}
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr style={{ borderTop: '2px solid var(--border)' }}>
<td style={{ padding: '8px 10px', fontWeight: 700 }}>Total</td>
<td style={{ padding: '8px 10px', fontWeight: 700, color: 'var(--success)' }}>{fmtEUR(overallTotals.depots)}</td>
<td style={{ padding: '8px 10px', fontWeight: 700, color: 'var(--danger)' }}>{fmtEUR(overallTotals.retraits)}</td>
<td style={{ padding: '8px 10px', fontWeight: 700 }}>{fmtEUR(overallTotals.diff)}</td>
<td style={{ padding: '8px 10px', fontWeight: 700 }}>
{overallTotals.objectifTotal != null ? fmtEUR(overallTotals.objectifTotal) : <span style={{ color: 'var(--text-muted)' }}></span>}
</td>
<td style={{ padding: '8px 10px', fontWeight: 700 }}>
{overallTotals.ecart == null ? (
<span style={{ color: 'var(--text-muted)' }}></span>
) : overallTotals.ecart >= 0 ? (
<span style={{ color: 'var(--success)' }}>+{fmtEUR(overallTotals.ecart)} au-delà de l'objectif</span>
) : (
<span style={{ color: 'var(--warning)' }}>Reste {fmtEUR(-overallTotals.ecart)}</span>
)}
</td>
</tr>
</tfoot>
</table>
{loading && <div style={{ padding: 8, color: 'var(--text-muted)', fontSize: 'var(--fs-sm)' }}>Chargement</div>}
</div>
);
}
+17
View File
@@ -25,6 +25,9 @@ function IconAide() {
function IconComm() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>;
}
function IconRefresh() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>;
}
function IconChevronRight() {
return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M9 18l6-6-6-6"/></svg>;
}
@@ -192,6 +195,17 @@ export default function UserMenu() {
const go = (path) => { closeMenu(); navigate(path); };
const handleLogout = () => { closeMenu(); logout(); navigate('/login'); };
// En mode standalone (webapp ajoutée à l'écran d'accueil iPad), il n'y a
// ni bouton reload ni pull-to-refresh natif. On force donc une vraie
// navigation réseau (pas juste reload()) avec un paramètre anti-cache,
// pour être certain de récupérer l'index.html et les données à jour.
const handleForceRefresh = () => {
closeMenu();
const url = new URL(window.location.href);
url.searchParams.set('_r', Date.now().toString());
window.location.replace(url.toString());
};
const selectView = (v) => {
setActiveView(v);
closeMenu();
@@ -268,6 +282,9 @@ export default function UserMenu() {
<button className="user-menu-item" role="menuitem" onClick={() => go('/aide')}>
<IconAide /> Aide
</button>
<button className="user-menu-item" role="menuitem" onClick={handleForceRefresh}>
<IconRefresh /> Recharger l'application
</button>
<div className="user-menu-sep" />
+6 -1
View File
@@ -67,10 +67,15 @@ export function AuthProvider({ children }) {
return r.user;
};
const deleteAccount = async (password) => {
await api.del('/auth/me', { password });
logout();
};
const isAdmin = user?.role === 'admin';
return (
<AuthCtx.Provider value={{ token, user, loading, login, completeLogin, register, logout, updateUser, isAdmin }}>
<AuthCtx.Provider value={{ token, user, loading, login, completeLogin, register, logout, updateUser, deleteAccount, isAdmin }}>
{children}
</AuthCtx.Provider>
);
+499 -12
View File
@@ -1,21 +1,107 @@
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { withDevOverrides } from '../utils/devOverrides.js';
/** Nom de fichier à partir de la question (slug, sans accents). */
function slugify(text) {
return text
.toLowerCase()
.normalize('NFD').replace(new RegExp('[\\u0300-\\u036f]', 'g'), '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 60);
}
/* ── Accordéon FAQ ───────────────────────────────────────────── */
function FaqItem({ question, children }) {
const [open, setOpen] = useState(false);
/* `openQuestion`/`setOpenQuestion` sont partagés par toutes les FaqItem d'une
même page (état remonté dans le composant parent) : une seule question
ouverte à la fois, ouvrir la suivante referme automatiquement les autres. */
function FaqItem({ question, children, openQuestion, setOpenQuestion }) {
const open = openQuestion === question;
const toggle = () => setOpenQuestion(open ? null : question);
const [pdfState, setPdfState] = useState('idle'); // idle | generating | error
const contentRef = useRef(null);
const handleDownloadPdf = async () => {
if (!contentRef.current || pdfState === 'generating') return;
setPdfState('generating');
// Insère le titre directement dans le bloc réel (contentRef), le temps de
// la capture uniquement, puis le retire évite de le dupliquer en
// permanence à l'écran tout en réutilisant l'élément réellement rendu
// (un conteneur hors-écran séparé est capturé vide par html2canvas :
// sa zone de rendu ne suit pas un élément poussé loin hors du viewport).
let heading = null;
try {
const { default: html2pdf } = await import('html2pdf.js');
// Fond capturé = fond réel du bloc (var(--surface)) : reste cohérent
// que le thème actif soit clair ou sombre, plutôt qu'un blanc forcé
// qui casserait le contraste du texte en mode sombre.
const bgColor = getComputedStyle(contentRef.current).backgroundColor || '#ffffff';
heading = document.createElement('h3');
heading.textContent = question;
heading.style.margin = '0 0 12px';
heading.style.color = 'var(--text)';
heading.style.fontSize = '1.1rem';
contentRef.current.insertBefore(heading, contentRef.current.firstChild);
await html2pdf()
.set({
margin: 28,
filename: `faq-${slugify(question) || 'crowdlending'}.pdf`,
image: { type: 'jpeg', quality: 0.95 },
html2canvas: { scale: 2, useCORS: true, backgroundColor: bgColor },
jsPDF: { unit: 'pt', format: 'a4', orientation: 'portrait' },
// 'avoid-all' : ne coupe jamais un élément (image, bloc de code,
// ligne de tableau) au milieu le pousse entièrement sur la page
// suivante à la place. C'est ce qui manquait avec le moteur HTML
// interne de jsPDF (doc.html()), qui tranchait au hasard.
pagebreak: { mode: ['avoid-all', 'css'] },
})
.from(contentRef.current)
.toPdf()
.get('pdf')
.then((pdf) => {
// Numérotation en bas à droite, une fois le nombre total de pages connu.
const total = pdf.internal.getNumberOfPages();
for (let i = 1; i <= total; i++) {
pdf.setPage(i);
pdf.setFontSize(9);
pdf.setTextColor(150);
pdf.text(
`${i} / ${total}`,
pdf.internal.pageSize.getWidth() - 28,
pdf.internal.pageSize.getHeight() - 16,
{ align: 'right' }
);
}
})
.save();
setPdfState('idle');
} catch (e) {
console.error('Échec de la génération du PDF :', e);
setPdfState('error');
} finally {
if (heading && heading.parentNode) heading.parentNode.removeChild(heading);
}
};
return (
<div style={{
borderBottom: '1px solid var(--border)',
padding: '0',
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 10,
boxShadow: 'var(--shadow)',
padding: '0 20px',
marginBottom: 12,
}}>
<button
onClick={() => setOpen(o => !o)}
onClick={toggle}
style={{
width: '100%', textAlign: 'left', background: 'none', border: 'none',
padding: '14px 0', cursor: 'pointer', display: 'flex',
alignItems: 'center', justifyContent: 'space-between', gap: 12,
color: 'var(--text)', fontSize: 'var(--fs-base)', fontWeight: 500,
color: 'var(--text)', fontSize: '1.05rem', fontWeight: 600,
}}
>
<span>{question}</span>
@@ -29,16 +115,81 @@ function FaqItem({ question, children }) {
</button>
{open && (
<div style={{
paddingBottom: 16, color: 'var(--text-muted)',
paddingBottom: 20, color: 'var(--text-muted)',
fontSize: 'var(--fs-sm)', lineHeight: 1.7,
}}>
{children}
<div ref={contentRef} style={{ background: 'var(--surface)' }}>
{children}
</div>
<button
onClick={handleDownloadPdf}
disabled={pdfState === 'generating'}
style={{
display: 'inline-flex', alignItems: 'center', gap: 6, marginTop: 16,
padding: '7px 14px', border: '1px solid var(--border)', borderRadius: 8,
background: 'var(--surface-2)', color: 'var(--text)',
fontSize: 'var(--fs-sm)', fontWeight: 500,
cursor: pdfState === 'generating' ? 'default' : 'pointer',
opacity: pdfState === 'generating' ? 0.6 : 1,
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
<path d="M12 3v12" />
<polyline points="7 10 12 15 17 10" />
<path d="M5 21h14" />
</svg>
{pdfState === 'generating' ? 'Génération du PDF…' : 'Télécharger cette FAQ en PDF'}
</button>
{pdfState === 'error' && (
<p style={{ color: 'var(--danger)', fontSize: 'var(--fs-sm)', margin: '8px 0 0' }}>
La génération du PDF a échoué. Réessayez, ou imprimez la page (Ctrl+P / Cmd+P) et choisissez
« Enregistrer en PDF ».
</p>
)}
</div>
)}
</div>
);
}
/* ── Titre de section (regroupement thématique de la FAQ) ──────── */
function FaqSectionTitle({ children, first }) {
return (
<h3 style={{
margin: first ? '0 0 12px' : '32px 0 12px',
fontSize: '0.8rem', fontWeight: 700, textTransform: 'uppercase',
letterSpacing: '0.06em', color: 'var(--text-muted)',
}}>
{children}
</h3>
);
}
/* ── Lien de téléchargement (fichiers statiques dans public/) ──── */
function DownloadLink({ href, children }) {
return (
<a
href={href}
download
style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
color: 'var(--primary)', fontSize: 'var(--fs-sm)', fontWeight: 500,
textDecoration: 'none', margin: '0 0 12px',
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
<path d="M12 3v12" />
<polyline points="7 10 12 15 17 10" />
<path d="M5 21h14" />
</svg>
{children}
</a>
);
}
/* ── Navigation ─────────────────────────────────────────────── */
const NAV = [
{
@@ -59,6 +210,17 @@ const NAV = [
export default function Aide() {
const { search } = useLocation();
const navigate = useNavigate();
const [openQuestion, setOpenQuestion] = useState(null); // question ouverte dans la FAQ (une seule à la fois)
const [appInfo, setAppInfo] = useState({}); // { appUrl, mcpUrl } valeurs brutes de la base, telles quelles
useEffect(() => {
fetch('/api/app-info').then(r => r.json()).then(setAppInfo).catch(() => {});
}, []);
// URL MCP annoncée comme « déjà pré-remplie » : doit refléter ce que
// Mon compte Serveur MCP affichera réellement, y compris en dev (où
// cette page-là retombe elle-même sur localhost via withDevOverrides).
const mcpConfigUrl = withDevOverrides(appInfo).mcpUrl || 'https://mcp.<votre domaine>/mcp';
const section = new URLSearchParams(search).get('section') || 'faq';
const setSection = (s) => navigate(`/aide?section=${s}`, { replace: true });
@@ -86,9 +248,11 @@ export default function Aide() {
{section === 'faq' && (
<div>
<h2 style={{ marginTop: 0, marginBottom: 24 }}>Questions fréquentes</h2>
<h2 style={{ marginTop: 0, marginBottom: 24, fontSize: '1.6rem', fontWeight: 700 }}>Questions fréquentes</h2>
<FaqItem question="Comment est calculé le solde du porte-monnaie d'une plateforme ?">
<FaqSectionTitle first>Comprendre la plateforme</FaqSectionTitle>
<FaqItem question="Comment est calculé le solde du porte-monnaie d'une plateforme ?" openQuestion={openQuestion} setOpenQuestion={setOpenQuestion}>
<p style={{ marginTop: 0 }}>
Le solde du porte-monnaie représente les liquidités disponibles sur une plateforme,
c'est-à-dire l'argent que vous pouvez retirer ou réinvestir. Il est calculé comme suit :
@@ -137,7 +301,7 @@ export default function Aide() {
permettant de réconcilier de micro-écarts de calcul (par exemple un arrondi de centimes sur la fiscalité).</p>
</FaqItem>
<FaqItem question="Comment mettre en place un réinvestissement automatique des intérêts ?">
<FaqItem question="Comment mettre en place un réinvestissement automatique des intérêts ?" openQuestion={openQuestion} setOpenQuestion={setOpenQuestion}>
<p style={{ marginTop: 0 }}>
Le réinvestissement automatique permet de capitaliser les intérêts perçus après chaque remboursement,
sans aucune saisie manuelle. Les intérêts sont automatiquement réinjectés dans le capital du prêt,
@@ -179,6 +343,329 @@ export default function Aide() {
</p>
</FaqItem>
<FaqSectionTitle>API et Serveur MCP pour l'IA</FaqSectionTitle>
<FaqItem question="Comment configurer le serveur MCP pour l'utiliser dans Claude Desktop" openQuestion={openQuestion} setOpenQuestion={setOpenQuestion}>
<p style={{ marginTop: 0 }}>
Le serveur MCP tourne déjà en continu (service Docker <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending-mcp</code>,
exposé via Traefik) vous n'avez rien à installer ni à laisser tourner sur votre machine. Il
suffit de connecter Claude Desktop à l'URL publique.
</p>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Étapes</h4>
<ol style={{ margin: '0 0 12px 16px', paddingLeft: 0, lineHeight: 1.8 }}>
<li>Générez une clé API : <strong style={{ color: 'var(--text)' }}>Mon compte Clés API Nouvelle clé</strong> (par exemple nommée « Claude Desktop »).</li>
<li>Allez dans <strong style={{ color: 'var(--text)' }}>Mon compte Serveur MCP</strong> et renseignez l'URL publique
(<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>{mcpConfigUrl}</code>{appInfo.mcpUrl ? ' — déjà pré-rempli si vous ne l\'avez pas modifié' : ''}) ainsi que la clé générée.</li>
<li>Copiez la configuration générée dans <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>claude_desktop_config.json</code> (Réglages
Développeur Serveurs MCP locaux Modifier la config) le mécanisme passe par
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}> mcp-remote</code> (avec le wrapper <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>cmd /c</code> sous
Windows), généré automatiquement pour vous.</li>
<li>Redémarrez complètement Claude Desktop.</li>
</ol>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Sécurité</h4>
<ul style={{ margin: '0 0 12px 16px', paddingLeft: 0, lineHeight: 1.8 }}>
<li>Le point d'entrée public n'a <strong style={{ color: 'var(--text)' }}>volontairement aucune restriction d'IP</strong> (accessible
depuis n'importe où, pour un usage nomade) : la clé API est donc la seule barrière d'accès. Traitez-la comme un
mot de passe, et révoquez-la immédiatement en cas de doute (Mon compte Clés API).</li>
<li>Limite de 60 requêtes/minute par IP (au-delà, erreur 429) et fermeture automatique des sessions inactives
depuis plus de 30 minutes aucune donnée de session n'est conservée entre deux connexions.</li>
<li>L'outil <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_fetch_url</code> (lecture d'une page web arbitraire) reste
<strong style={{ color: 'var(--text)' }}> désactivé</strong> sur ce serveur.</li>
</ul>
<p style={{ marginBottom: 0 }}>
Si la connexion reste bloquée sans erreur visible, la cause est presque toujours la même :
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}> npx</code> qui échoue silencieusement à joindre le registre npm voir la
section Dépannage de <strong style={{ color: 'var(--text)' }}>Mon compte Serveur MCP</strong>.
</p>
</FaqItem>
<FaqItem question="Comment utiliser le serveur MCP au quotidien ? (fonctions disponibles et exemples)" openQuestion={openQuestion} setOpenQuestion={setOpenQuestion}>
<p style={{ marginTop: 0 }}>
Une fois connecté, Claude (Desktop ou tout autre client MCP) peut consulter votre portefeuille en langage
naturel il choisit lui-même le bon outil selon votre question. Le serveur est <strong style={{ color: 'var(--text)' }}>strictement
en lecture seule</strong> : aucune donnée n'est jamais créée, modifiée ou supprimée depuis une conversation. Toute
saisie (nouvel investissement, remboursement) reste manuelle dans l'application.
</p>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Fonctions disponibles</h4>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 'var(--fs-sm)', margin: '0 0 14px' }}>
<tbody>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_get_investisseur</code>
</td>
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>Profil de l'investisseur lié à la clé API (nom, type famille/entreprise, régime fiscal). Avec une clé « Famille et entreprises », renvoie la liste des profils du foyer.</td>
</tr>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_get_dashboard</code>
</td>
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>KPIs du portefeuille : capital investi, capital en risque, montant remboursé, intérêts bruts/nets, dépôts/retraits filtrable par année.</td>
</tr>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_list_investissements</code>
</td>
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>Liste des investissements (projet, émetteur, plateforme, montant, taux, durée, statut), filtrable par statut.</td>
</tr>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_get_investissement</code>
</td>
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>Détail complet d'un investissement (par id), y compris la liste de ses remboursements réels perçus.</td>
</tr>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_list_remboursements</code>
</td>
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>Historique des remboursements perçus (toutes plateformes), filtrable par période.</td>
</tr>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_list_depots_retraits</code>
</td>
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>Historique des mouvements de cash (dépôts et retraits), du plus récent au plus ancien.</td>
</tr>
<tr>
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_fetch_url</code>
</td>
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>
<em>Développement local uniquement</em>, désactivé par défaut. Lit une page web (ex. annonce de projet sur
une plateforme) et en extrait le texte propre c'est à vous d'en reprendre les informations utiles pour
créer l'investissement manuellement, l'outil ne saisit rien lui-même.
</td>
</tr>
</tbody>
</table>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Exemples de questions</h4>
<ul style={{ margin: '0 0 12px 16px', paddingLeft: 0, lineHeight: 2 }}>
<li>« Quel est mon encours de crowdlending actuellement ? »</li>
<li>« Quel a été mon rendement (intérêts nets) en 2026 ? »</li>
<li>« Liste-moi les investissements en retard ou en procédure. »</li>
<li>« Donne-moi le détail de l'investissement 42, avec ses remboursements. »</li>
<li>« Quels remboursements ai-je reçus entre le 1er et le 30 juin 2026 ? »</li>
<li>« Quels ont été mes derniers dépôts et retraits ? »</li>
<li>« Regarde cette annonce de projet et propose-moi les infos pour créer l'investissement : [URL] »
(développement local, avec <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_fetch_url</code> activé)</li>
</ul>
<p style={{ marginBottom: 0 }}>
Ces exemples fonctionnent aussi bien en dev qu'en prod dès lors que le serveur correspondant est connecté
(voir les FAQ de configuration ci-dessus) les six premiers outils sont identiques dans les deux environnements.
</p>
</FaqItem>
<FaqItem question="Comment interroger mon portefeuille depuis Excel avec Power Query ?" openQuestion={openQuestion} setOpenQuestion={setOpenQuestion}>
<p style={{ marginTop: 0 }}>
Comme le serveur MCP, Power Query s'appuie sur l'API v1 (lecture seule) via une clé API.
Power Query est intégré nativement à Excel (365 / 2016 et plus, Windows ou Mac) rien à
installer. Chaque étape ci-dessous correspond à un écran que vous pouvez capturer pour
illustrer votre propre guide.
</p>
<div>
<DownloadLink href="/powerquery/crowdlending-powerquery-exemple.xlsb">
Télécharger le classeur complet (.xlsb)
</DownloadLink>
</div>
<p style={{ marginTop: 0, marginBottom: 16 }}>
Le classeur ci-dessus contient déjà les 7 requêtes ci-dessous, prêtes à charger il ne
reste qu'à renseigner votre clé (étape 1). Vous pouvez aussi tout reconstruire à la main
avec les fichiers <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>.pq</code> individuels
proposés à chaque étape ci-dessous.
</p>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Étape 1 Générer une clé API</h4>
<ol style={{ margin: '0 0 12px 16px', paddingLeft: 0, lineHeight: 1.8 }}>
<li><strong style={{ color: 'var(--text)' }}>Mon compte Clés API Nouvelle clé</strong>.</li>
<li>Donnez-lui un nom explicite (ex. « Excel »), validez, puis copiez immédiatement la clé
affichée elle ne sera plus visible en clair ensuite.</li>
</ol>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Étape 2 Créer les deux requêtes de connexion</h4>
<p>
Dans Excel : <strong style={{ color: 'var(--text)' }}>Données Obtenir des données À partir d'autres
sources Requête vide</strong>. Une nouvelle requête « Requête1 » apparaît dans l'éditeur Power Query.
</p>
<ol style={{ margin: '0 0 8px 16px', paddingLeft: 0, lineHeight: 1.8 }}>
<li>Renommez-la <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>ApiBaseUrl</code> (double-clic
sur son nom dans le volet Requêtes), puis <strong style={{ color: 'var(--text)' }}>Affichage Éditeur avancé</strong>,
effacez le contenu par défaut et collez :</li>
</ol>
<pre style={{ margin: '0 0 12px', padding: '12px 16px', background: 'var(--surface-2)', borderRadius: 8, fontFamily: 'monospace', fontSize: 'var(--fs-sm)', color: 'var(--text)', whiteSpace: 'pre-wrap', overflowX: 'auto' }}>
{`"http://localhost:4000/api/v1"`}
</pre>
<DownloadLink href="/powerquery/queries/ApiBaseUrl.pq">Télécharger ApiBaseUrl.pq</DownloadLink>
<img
src="/powerquery/apibaseurl-editeur-avance.png"
alt="Éditeur avancé Power Query — requête ApiBaseUrl avec l'URL de l'API en production"
style={{ width: '100%', maxWidth: 600, borderRadius: 8, border: '1px solid var(--border)', display: 'block', margin: '0 0 16px' }}
onError={(e) => { e.currentTarget.style.display = 'none'; }}
/>
<ol start={2} style={{ margin: '0 0 8px 16px', paddingLeft: 0, lineHeight: 1.8 }}>
<li>Cliquez <strong style={{ color: 'var(--text)' }}>Terminé</strong>, puis <strong style={{ color: 'var(--text)' }}>Accueil Fermer et charger dans Uniquement créer la connexion</strong> (pas besoin d'un tableau pour celle-ci).</li>
<li>Répétez l'opération pour une seconde requête vide nommée <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>ApiKey</code> :</li>
</ol>
<pre style={{ margin: '0 0 12px', padding: '12px 16px', background: 'var(--surface-2)', borderRadius: 8, fontFamily: 'monospace', fontSize: 'var(--fs-sm)', color: 'var(--text)', whiteSpace: 'pre-wrap', overflowX: 'auto' }}>
{`"clk_live_VOTRE_CLE_API"`}
</pre>
<DownloadLink href="/powerquery/queries/ApiKey.pq">Télécharger ApiKey.pq</DownloadLink>
<img
src="/powerquery/apikey-editeur-avance.png"
alt="Éditeur avancé Power Query — requête ApiKey avec la clé API renseignée"
style={{ width: '100%', maxWidth: 600, borderRadius: 8, border: '1px solid var(--border)', display: 'block', margin: '0 0 16px' }}
onError={(e) => { e.currentTarget.style.display = 'none'; }}
/>
<p>Remplacez par la clé copiée à l'étape 1, entre guillemets. Même chose : Terminé Uniquement créer la connexion.</p>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Étape 3 La fonction technique fnApiGet</h4>
<p>
Une troisième requête vide, nommée <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>fnApiGet</code>,
fait l'appel HTTP et transmet la clé dans l'en-tête <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>X-API-Key</code>
les requêtes suivantes la réutiliseront par son nom, sans la récrire :
</p>
<pre style={{ margin: '0 0 12px', padding: '12px 16px', background: 'var(--surface-2)', borderRadius: 8, fontFamily: 'monospace', fontSize: 'var(--fs-sm)', color: 'var(--text)', whiteSpace: 'pre-wrap', overflowX: 'auto' }}>
{`(chemin as text, optional parametres as nullable record) as any =>
let
parametresBruts = if parametres = null then [] else parametres,
champsUtiles = List.Select(
Record.FieldNames(parametresBruts),
each Record.Field(parametresBruts, _) <> null and Record.Field(parametresBruts, _) <> ""
),
parametresNettoyes = Record.SelectFields(parametresBruts, champsUtiles),
reponse = Web.Contents(
ApiBaseUrl,
[
RelativePath = chemin,
Headers = [#"X-API-Key" = ApiKey, #"Accept" = "application/json"],
Query = parametresNettoyes
]
),
resultat = Json.Document(reponse)
in
resultat`}
</pre>
<DownloadLink href="/powerquery/queries/fnApiGet.pq">Télécharger fnApiGet.pq</DownloadLink>
<img
src="/powerquery/fnapiget-editeur-avance.png"
alt="Éditeur avancé Power Query — fonction fnApiGet"
style={{ width: '100%', maxWidth: 700, borderRadius: 8, border: '1px solid var(--border)', display: 'block', margin: '0 0 16px' }}
onError={(e) => { e.currentTarget.style.display = 'none'; }}
/>
<p>Terminé Fermer et charger dans Uniquement créer la connexion (ce n'est pas une donnée à afficher).</p>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Étape 4 Première requête de données : Investissements</h4>
<p>Une nouvelle requête vide, nommée <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>Investissements</code> :</p>
<pre style={{ margin: '0 0 12px', padding: '12px 16px', background: 'var(--surface-2)', borderRadius: 8, fontFamily: 'monospace', fontSize: 'var(--fs-sm)', color: 'var(--text)', whiteSpace: 'pre-wrap', overflowX: 'auto' }}>
{`let
// Pour filtrer par statut, remplacez null par [statut = "rembourse"]
// Statuts possibles : en_cours, rembourse, en_retard, procedure, cloture
Source = fnApiGet("/investissements", null),
Resultat = Table.FromRecords(Source)
in
Resultat`}
</pre>
<DownloadLink href="/powerquery/queries/Investissements.pq">Télécharger Investissements.pq</DownloadLink>
<img
src="/powerquery/investissements-editeur-avance.png"
alt="Éditeur avancé Power Query — requête Investissements avec aperçu des données chargées"
style={{ width: '100%', maxWidth: 700, borderRadius: 8, border: '1px solid var(--border)', display: 'block', margin: '0 0 16px' }}
onError={(e) => { e.currentTarget.style.display = 'none'; }}
/>
<p>
Cette fois-ci, <strong style={{ color: 'var(--text)' }}>Terminé Fermer et charger</strong> (le bouton
simple, pas « ») : la requête s'ajoute comme tableau dans une nouvelle feuille, avec vos
investissements en ligne et en colonnes.
</p>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Étape 5 Actualiser</h4>
<p style={{ marginBottom: 0 }}>
<strong style={{ color: 'var(--text)' }}>Données Actualiser tout</strong> (ou Ctrl+Alt+F5) à tout moment
pour recharger les données depuis l'API utile après un nouvel investissement ou remboursement
saisi dans l'app.
</p>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Aller plus loin les autres requêtes</h4>
<p>Même principe (requête vide nom exact éditeur avancé coller charger) pour :</p>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 'var(--fs-sm)', margin: '0 0 14px' }}>
<tbody>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>Investisseur</code>
</td>
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>Profil investisseur (ou liste des membres si clé « Famille et entreprises »).</td>
<td style={{ padding: '8px 0 8px 10px', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<DownloadLink href="/powerquery/queries/Investisseur.pq">.pq</DownloadLink>
</td>
</tr>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>Dashboard</code>
</td>
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>KPIs du portefeuille, au format « Indicateur / Valeur » (facile à croiser dans un TCD).</td>
<td style={{ padding: '8px 0 8px 10px', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<DownloadLink href="/powerquery/queries/Dashboard.pq">.pq</DownloadLink>
</td>
</tr>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>Remboursements</code>
</td>
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>Historique des remboursements, filtrable par période.</td>
<td style={{ padding: '8px 0 8px 10px', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<DownloadLink href="/powerquery/queries/Remboursements.pq">.pq</DownloadLink>
</td>
</tr>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>DépôtsRetraits</code>
</td>
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>Historique des mouvements de cash.</td>
<td style={{ padding: '8px 0 8px 10px', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<DownloadLink href="/powerquery/queries/DepotsRetraits.pq">.pq</DownloadLink>
</td>
</tr>
<tr>
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>fnDetailInvestissement</code>
</td>
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>
Fonction avancée : sur la table <em>Investissements</em>, colonne personnalisée{' '}
<code style={{ fontFamily: 'monospace' }}>= fnDetailInvestissement([id])</code>{' '}
pour ramener le détail + les remboursements de chaque prêt.
</td>
<td style={{ padding: '8px 0 8px 10px', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<DownloadLink href="/powerquery/queries/fnDetailInvestissement.pq">.pq</DownloadLink>
</td>
</tr>
</tbody>
</table>
<p>
Toutes ces requêtes sont déjà incluses dans le classeur téléchargeable en haut de cette fiche
les fichiers <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>.pq</code> ci-dessus
ne sont utiles que si vous préférez les coller vous-même dans un classeur existant.
</p>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Dépannage</h4>
<ul style={{ margin: '0 0 12px 16px', paddingLeft: 0, lineHeight: 1.8 }}>
<li><strong style={{ color: 'var(--text)' }}>« Impossible de se connecter au service distant »</strong> le
backend n'est pas démarré, ou <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>ApiBaseUrl</code> est
incorrecte (vérifiez le port et le suffixe <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>/api/v1</code>).</li>
<li><strong style={{ color: 'var(--text)' }}>Erreur 401 / clé invalide</strong> régénérez une clé dans
Mon compte Clés API et mettez à jour la requête <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>ApiKey</code>.</li>
<li><strong style={{ color: 'var(--text)' }}>Avertissement de confidentialité au premier chargement</strong>
normal pour toute nouvelle source Web dans Power Query ; choisissez « Organisationnel » ou « Public »
selon votre contexte.</li>
</ul>
<p style={{ marginBottom: 0 }}>
Comme le serveur MCP, ces requêtes sont strictement en lecture seule : aucune saisie n'est possible
depuis Excel, toute modification reste à faire dans l'application.
</p>
</FaqItem>
</div>
)}
+12
View File
@@ -483,6 +483,18 @@ export default function Dashboard() {
<EmptyState />
</>
);
if (platLoaded && data.portfolio.nb_projets === 0) return (
<>
<div className="topbar"><h2>Tableau de bord</h2></div>
<EmptyState
icon="📊"
title="Aucun investissement pour le moment"
message="Vos plateformes sont configurées, mais aucun investissement n'y a encore été ajouté. Ajoutez un investissement pour voir apparaître les données de votre tableau de bord."
cta="Ajouter un investissement"
to="/investissements?new=1"
/>
</>
);
const { cash, cashByPlatform, portfolio, interets, interetsParAnnee } = data;
const viewTitle = activeView === 'all'
+106 -23
View File
@@ -12,9 +12,16 @@ import SoldeChart from '../components/SoldeChart.jsx';
import DistributionChart from '../components/DistributionChart.jsx';
import { fmtEUR, fmtDate, today } from '../utils/format.js';
import DepotsMensuelTable from '../components/DepotsMensuelTable.jsx';
import SuiviObjectifs from '../components/SuiviObjectifs.jsx';
import * as XLSX from 'xlsx';
/* ── Helpers export ──────────────────────────────────────────── */
/** Horodatage local AAAAMMJJ_HHmmss (jamais toISOString(), cf. décalage UTC) */
function timestampSuffix() {
const d = new Date();
const pad = n => String(n).padStart(2, '0');
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
}
function dlBlob(content, filename, type) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
@@ -60,11 +67,11 @@ function mouvToCSV(rows) {
return BOM + [headers,...data].map(r => r.map(q).join(sep)).join('\r\n');
}
function mouvToXLS(rows) {
function mouvToXLS(rows, multiDetenteur) {
const data = rows.map(r => ({
'Date': r.date_operation,
'Plateforme': r.plateforme_nom || '',
'Détenteur': r.plateforme_detenteur_nom || '',
...(multiDetenteur ? { 'Détenteur': r.plateforme_detenteur_nom || '' } : {}),
'Type': r.type === 'depot' ? 'Dépôt' : 'Retrait',
'Montant (€)': r.montant,
}));
@@ -375,20 +382,30 @@ export default function DepotsRetraits() {
const [allRows, setAllRows] = useState([]);
const [plats, setPlats] = useState([]);
const [dashData, setDashData] = useState(null);
const [filter, setFilter] = useState({ type: '', plateforme_ids: [], year: '', month: '' });
// Année sélectionnée, partagée entre l'onglet Plateformes et le filtre "Année" de Mouvements
const currentYear = String(new Date().getFullYear());
const [filter, setFilter] = useState({ type: '', plateforme_ids: [], year: currentYear, month: '' });
const [modalOpen, setModalOpen] = useState(false);
const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState(empty);
const [err, setErr] = useState(null);
const [loading, setLoading] = useState(false);
const [selectedRow, setSelectedRow] = useState(null);
const [drPlatYear, setDrPlatYear] = useState(String(new Date().getFullYear()));
const [drPlatYear, setDrPlatYear] = useState(currentYear);
const [allRemb, setAllRemb] = useState([]);
const [allInv, setAllInv] = useState([]);
const [allCorrections, setAllCorrections] = useState([]);
const [deleteConfirm, setDeleteConfirm] = useState(null);
/* Objectifs de versement annuel — pour enrichir le KPI "Diff. Dépôts vs Retraits" */
const [objectifsKpi, setObjectifsKpi] = useState([]);
useEffect(() => {
api.get('/objectifs', { type: 'versement_annuel' })
.then(r => setObjectifsKpi(Array.isArray(r) ? r : []))
.catch(() => setObjectifsKpi([]));
}, []);
/* Lecture des params de navigation depuis la page Plateformes */
useEffect(() => {
const p = new URLSearchParams(search);
@@ -401,6 +418,7 @@ export default function DepotsRetraits() {
const platIds = p.get('plat_ids');
if (platIds) newFilter.plateforme_ids = platIds.split(',').filter(Boolean);
setFilter(newFilter);
setDrPlatYear(newFilter.year); // garde l'onglet Plateformes synchronisé avec l'année du drill-down
navigate('/depots-retraits', { replace: true });
}, []); // eslint-disable-line
const [openMenu, setOpenMenu] = useState(null); // { row, x, y }
@@ -672,6 +690,26 @@ export default function DepotsRetraits() {
return balance;
})();
/* Enrichissement du KPI "Diff. Dépôts vs Retraits" avec l'objectif de l'année
Toujours calculé sur le portefeuille entier (ignore le filtre plateforme),
pour l'année sélectionnée (drPlatYear, ou l'année en cours si "Toutes les années"). */
const scopeInvestisseurIdsKpi = activeView === 'all' ? investisseurs.map(i => i.id) : [activeId];
const kpiObjectifYear = Number(drPlatYear || new Date().getFullYear());
const kpiObjectifTotal = (() => {
const list = objectifsKpi.filter(o => o.annee === kpiObjectifYear && scopeInvestisseurIdsKpi.includes(o.investisseur_id));
return list.length ? list.reduce((s, o) => s + o.montant, 0) : null;
})();
const kpiDiffGlobalYear = (() => {
let d = 0, r = 0;
for (const row of allRows) {
if (!scopeInvestisseurIdsKpi.includes(row.investisseur_id)) continue;
if (row.date_operation?.slice(0, 4) !== String(kpiObjectifYear)) continue;
if (row.type === 'depot') d += row.montant; else r += row.montant;
}
return d - r;
})();
const kpiEcartObjectif = kpiObjectifTotal != null ? kpiDiffGlobalYear - kpiObjectifTotal : null;
const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1;
/** Investisseur par défaut selon la vue active */
@@ -889,13 +927,25 @@ export default function DepotsRetraits() {
<EmptyState />
</>
);
if (!loading && !modalOpen && !corrModal.open && allRows.length === 0) return (
<>
<div className="topbar"><h2>Dépôts / Retraits</h2></div>
<EmptyState
icon="💶"
title="Aucun dépôt enregistré"
message="Vos plateformes sont configurées, mais aucun dépôt n'a encore été enregistré. Ajoutez un dépôt pour commencer à suivre votre solde."
cta="Ajouter un dépôt"
to="/depots-retraits?new=1"
/>
</>
);
return (
<>
<div className="topbar">
<h2><PageIcon name="depots-retraits" />Dépôts / Retraits</h2>
{(filter.type || filter.plateforme_ids.length > 0 || filter.year || filter.month) && (
{(filter.type || filter.plateforme_ids.length > 0 || filter.month) && (
<button className="dr-clear-btn"
onClick={() => setFilter({ type: '', plateforme_ids: [], year: '', month: '' })}>
onClick={() => { setFilter({ type: '', plateforme_ids: [], year: '', month: '' }); setDrPlatYear(''); }}>
Effacer les filtres
</button>
)}
@@ -934,10 +984,10 @@ export default function DepotsRetraits() {
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.retraits)} en {prevYear}</div>
</div>
<div
className={`kpi${(filter.type || filter.plateforme_ids.length > 0 || filter.year || filter.month) ? ' dr-kpi-clickable dr-kpi-active dr-kpi-active-neutral' : ''}`}
onClick={() => setFilter({ type: '', plateforme_ids: [], year: '', month: '' })}
title={(filter.type || filter.plateforme_ids.length > 0 || filter.year || filter.month) ? 'Effacer les filtres' : undefined}
style={(filter.type || filter.plateforme_ids.length > 0 || filter.year || filter.month) ? { cursor: 'pointer' } : {}}
className={`kpi${(filter.type || filter.plateforme_ids.length > 0 || filter.month) ? ' dr-kpi-clickable dr-kpi-active dr-kpi-active-neutral' : ''}`}
onClick={() => { setFilter({ type: '', plateforme_ids: [], year: '', month: '' }); setDrPlatYear(''); }}
title={(filter.type || filter.plateforme_ids.length > 0 || filter.month) ? 'Effacer les filtres' : undefined}
style={(filter.type || filter.plateforme_ids.length > 0 || filter.month) ? { cursor: 'pointer' } : {}}
>
<div className="label">Diff. Dépôts vs Retraits</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
@@ -945,6 +995,16 @@ export default function DepotsRetraits() {
<TrendBadge current={totals.depots - totals.retraits} prev={prevTotals.depots - prevTotals.retraits} />
</div>
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.depots - prevTotals.retraits)} en {prevYear}</div>
{kpiEcartObjectif != null && (
<div style={{
fontSize: '0.8em', marginTop: 4, fontWeight: 600,
color: kpiEcartObjectif >= 0 ? 'var(--success)' : 'var(--warning)',
}}>
{kpiEcartObjectif >= 0
? `+${fmtEUR(kpiEcartObjectif)} au-delà de l'objectif ${kpiObjectifYear}`
: `Reste ${fmtEUR(-kpiEcartObjectif)} pour l'objectif ${kpiObjectifYear}`}
</div>
)}
</div>
<div className="kpi" title="Cash disponible sur les porte-monnaie des plateformes (hors compte courant)">
<div className="label">Porte-monnaie</div>
@@ -960,12 +1020,25 @@ export default function DepotsRetraits() {
{!listFocused && <div className="dr-tabs">
<button className={`dr-tab${activeTab === 'plateformes' ? ' active' : ''}`}
onClick={() => setActiveTab('plateformes')}>Plateformes</button>
<button className={`dr-tab${activeTab === 'vision-annuelle' ? ' active' : ''}`}
onClick={() => setActiveTab('vision-annuelle')}>Vision annuelle</button>
<button className={`dr-tab${activeTab === 'vision-mensuelle' ? ' active' : ''}`}
onClick={() => setActiveTab('vision-mensuelle')}>Vision mensuelle</button>
<button className={`dr-tab${activeTab === 'mouvements' ? ' active' : ''}`}
onClick={() => { setActiveTab('mouvements'); setSelectedRow(r => r ?? (rows[0] || null)); }}>Mouvements</button>
</div>}
{/* ====== ONGLET VISION ANNUELLE ====== */}
{activeTab === 'vision-annuelle' && (
<div style={{ padding: '0 24px' }}>
<SuiviObjectifs
rows={allRows}
investisseurs={investisseurs}
scopeInvestisseurIds={scopeInvestisseurIdsKpi}
/>
</div>
)}
{/* ====== ONGLET PLATEFORMES ====== */}
{activeTab === 'plateformes' && (
<div style={{ padding: '0 24px' }}>
@@ -976,7 +1049,7 @@ export default function DepotsRetraits() {
{drPlatYear && <span style={{ marginLeft: 8 }}>pour l'année {drPlatYear}</span>}
</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<select value={drPlatYear} onChange={e => setDrPlatYear(e.target.value)}
<select value={drPlatYear} onChange={e => { const v = e.target.value; setDrPlatYear(v); setFilter(f => ({ ...f, year: v })); }}
style={{
fontSize: 'var(--fs-xs)', padding: '4px 8px', height: 30,
borderRadius: 6, border: '1px solid var(--border)',
@@ -1017,11 +1090,11 @@ export default function DepotsRetraits() {
) : (
<table>
<thead>
<tr><th>Plateforme</th><th>Détenteur</th><th className="num">Dépôts</th><th className="num">Retraits</th><th className="num">Diff. Dépôts vs Retraits</th><th className="num">Porte-monnaie</th><th className="num">Poids</th><th style={{ width: 28 }}></th></tr>
<tr><th>Plateforme</th>{multiDetenteur && <th>Détenteur</th>}<th className="num">Dépôts</th><th className="num">Retraits</th><th className="num">Diff. Dépôts vs Retraits</th><th className="num">Porte-monnaie</th><th className="num">Poids</th><th style={{ width: 28 }}></th></tr>
</thead>
<tbody>
{drPlatData.length === 0 && (
<tr><td colSpan={8} className="text-muted" style={{ textAlign: 'center' }}>Aucune plateforme</td></tr>
<tr><td colSpan={multiDetenteur ? 8 : 7} className="text-muted" style={{ textAlign: 'center' }}>Aucune plateforme</td></tr>
)}
{(() => {
const totalSolde = drPlatTotals.solde_net;
@@ -1041,7 +1114,7 @@ export default function DepotsRetraits() {
<td>
<span style={{ fontWeight: isActive ? 600 : undefined }}>{p.nom}</span>
</td>
<td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{p.detenteur_nom || '—'}</td>
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{p.detenteur_nom || '—'}</td>}
<td className="num">{fmtEUR(p.depots)}</td>
<td className="num">{fmtEUR(p.retraits)}</td>
<td className="num">{fmtEUR(p.solde_net)}</td>
@@ -1068,7 +1141,7 @@ export default function DepotsRetraits() {
<tfoot>
<tr style={{ borderTop: '2px solid var(--border)', fontWeight: 700, background: 'var(--surface-2)' }}>
<td>Total {drPlatData.length} plateformes</td>
<td />
{multiDetenteur && <td />}
<td className="num">{fmtEUR(drPlatTotals.depots)}</td>
<td className="num">{fmtEUR(drPlatTotals.retraits)}</td>
<td className="num">{fmtEUR(drPlatTotals.solde_net)}</td>
@@ -1142,10 +1215,20 @@ export default function DepotsRetraits() {
</button>
<ExportDropdown
disabled={rows.length === 0}
onCSV={() => dlBlob(mouvToCSV(rows), 'mouvements.csv', 'text/csv;charset=utf-8')}
onXLS={() => dlBlob(mouvToXLS(rows), 'mouvements.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')}
onJSON={() => dlBlob(mouvToJSON(rows), 'mouvements.json', 'application/json')}
onCSV={() => dlBlob(mouvToCSV(rows), `Mouvements Dépôts Retraits ${timestampSuffix()}.csv`, 'text/csv;charset=utf-8')}
onXLS={() => dlBlob(mouvToXLS(rows, multiDetenteur), `Mouvements Dépôts Retraits ${timestampSuffix()}.xlsx`, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')}
onJSON={() => dlBlob(mouvToJSON(rows), `Mouvements Dépôts Retraits ${timestampSuffix()}.json`, 'application/json')}
/>
<button
type="button"
className="icon-btn"
title="Importer des dépôts/retraits"
onClick={() => navigate('/settings?section=imports&module=depots_retraits')}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
</svg>
</button>
</div>
</div>
@@ -1205,7 +1288,7 @@ export default function DepotsRetraits() {
</div>
<div style={{ flex: 1, minWidth: 100 }}>
<label>Année</label>
<select value={filter.year} onChange={e => setFilter({ ...filter, year: e.target.value })}>
<select value={filter.year} onChange={e => { const v = e.target.value; setFilter(f => ({ ...f, year: v })); setDrPlatYear(v); }}>
<option value="">Toutes</option>
{years.map(y => <option key={y} value={y}>{y}</option>)}
</select>
@@ -1224,12 +1307,12 @@ export default function DepotsRetraits() {
<table>
<thead>
<tr>
<th>Date</th><th>Plateforme</th><th>Détenteur</th><th>Type de mouvement</th><th className="num">Montant</th><th style={{ width: 36 }}></th>
<th>Date</th><th>Plateforme</th>{multiDetenteur && <th>Détenteur</th>}<th>Type de mouvement</th><th className="num">Montant</th><th style={{ width: 36 }}></th>
</tr>
</thead>
<tbody>
{tableRows.length === 0 && (
<tr><td colSpan={5} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
<tr><td colSpan={multiDetenteur ? 6 : 5} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
{loading ? 'Chargement…' : 'Aucun mouvement'}
</td></tr>
)}
@@ -1243,7 +1326,7 @@ export default function DepotsRetraits() {
style={{ cursor: 'pointer' }}>
<td>{fmtDate(r.date_operation)}</td>
<td>{r.plateforme_nom}</td>
<td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{r.plateforme_detenteur_nom || '—'}</td>
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{r.plateforme_detenteur_nom || '—'}</td>}
<td>
<span style={{
display: 'inline-block',
@@ -1272,7 +1355,7 @@ export default function DepotsRetraits() {
style={{ cursor: 'pointer', opacity: isAuto ? 0.8 : 1 }}>
<td>{fmtDate(r.date_operation)}</td>
<td>{r.plateforme_nom}</td>
<td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{r.plateforme_detenteur_nom || '—'}</td>
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{r.plateforme_detenteur_nom || '—'}</td>}
<td>
<span className={`badge ${r.type}`}>{r.type}</span>
{isAuto && (
+392 -42
View File
@@ -7,6 +7,7 @@ import Modal from '../components/Modal.jsx';
import ConfirmModal from '../components/ConfirmModal.jsx';
import CountrySelect, { COUNTRIES, FlagIcon } from '../components/CountrySelect.jsx';
import { fmtEUR, fmtPct, fmtDate, today } from '../utils/format.js';
import { xirr } from '../utils/xirr.js';
const emptyForm = {
investisseur_id: '', plateforme_id: '', nom_projet: '', emetteur: '',
@@ -72,33 +73,6 @@ const STATUT_META = {
cloture: { label: 'Clôturé', color: '#6b7280', bg: 'rgba(107,114,128,0.12)' },
};
/**
* XIRR taux de rendement interne annualisé sur flux datés.
* cashflows : [{ amount: number, date: string 'YYYY-MM-DD' }]
* le premier flux doit être négatif (investissement initial).
* Retourne le taux annualisé (ex: 0.112 = 11.2%) ou null si non convergent.
*/
function xirr(cashflows) {
if (!cashflows || cashflows.length < 2) return null;
const t0 = new Date(cashflows[0].date).getTime();
const years = cashflows.map(cf => (new Date(cf.date).getTime() - t0) / (365.25 * 864e5));
const amounts = cashflows.map(cf => cf.amount);
const npv = r => amounts.reduce((s, a, i) => s + a / Math.pow(1 + r, years[i]), 0);
const dnpv = r => amounts.reduce((s, a, i) => s - years[i] * a / Math.pow(1 + r, years[i] + 1), 0);
let rate = 0.1;
for (let i = 0; i < 200; i++) {
const f = npv(rate);
const df = dnpv(rate);
if (Math.abs(df) < 1e-12) break;
const next = rate - f / df;
if (Math.abs(next - rate) < 1e-8) return isFinite(next) ? next : null;
rate = next;
if (rate < -0.999) rate = -0.999; // garde-fou
}
return null;
}
export default function InvestissementDetail() {
const { id } = useParams();
@@ -160,6 +134,17 @@ export default function InvestissementDetail() {
const [confirmingRembDelete, setConfirmingRembDelete] = useState(false);
const [confirmingInvDelete, setConfirmingInvDelete] = useState(false);
const [confirmingHistDelete, setConfirmingHistDelete] = useState(null); // id de l'entrée à supprimer
// Modal révision des conditions de prêt (taux/date suite à un événement)
const [revisionModalOpen, setRevisionModalOpen] = useState(false);
const [revisionForm, setRevisionForm] = useState({
date_effet: today(),
changeTaux: false, nouveau_taux: '',
changeDate: false, nouvelle_date_cible: '',
motif: '',
});
const [revisionErr, setRevisionErr] = useState(null);
const [revisionSaving, setRevisionSaving] = useState(false);
const [confirmingRevisionDelete, setConfirmingRevisionDelete] = useState(null); // id de la révision à supprimer
const [loading, setLoading] = useState(true);
const [recalculating, setRecalculating] = useState(false);
// Modal réinvestissement
@@ -611,6 +596,62 @@ export default function InvestissementDetail() {
} catch (e) { /* silencieux */ }
};
const openRevisionModal = () => {
setRevisionForm({
date_effet: today(),
changeTaux: false, nouveau_taux: '',
changeDate: false, nouvelle_date_cible: '',
motif: '',
});
setRevisionErr(null);
setRevisionModalOpen(true);
};
const closeRevisionModal = () => { setRevisionModalOpen(false); setRevisionErr(null); };
const submitRevision = async (e) => {
e?.preventDefault?.();
setRevisionErr(null);
if (!revisionForm.date_effet) { setRevisionErr("La date d'effet est requise."); return; }
if (!revisionForm.motif.trim()) { setRevisionErr('Le motif est obligatoire.'); return; }
if (!revisionForm.changeTaux && !revisionForm.changeDate) {
setRevisionErr('Cochez au moins « Modifier le taux » ou « Modifier la date cible ».');
return;
}
if (revisionForm.changeTaux && revisionForm.nouveau_taux === '') {
setRevisionErr('Saisissez le nouveau taux annuel.');
return;
}
if (revisionForm.changeDate && !revisionForm.nouvelle_date_cible) {
setRevisionErr('Saisissez la nouvelle date cible.');
return;
}
setRevisionSaving(true);
try {
await api.post(`/investissements/${id}/revisions`, {
date_effet: revisionForm.date_effet,
nouveau_taux: revisionForm.changeTaux ? Number(revisionForm.nouveau_taux) : undefined,
nouvelle_date_cible: revisionForm.changeDate ? revisionForm.nouvelle_date_cible : undefined,
motif: revisionForm.motif.trim(),
});
setRevisionModalOpen(false);
await load();
} catch (e) {
// Surface le détail des erreurs de validation (Zod) plutôt que le message générique
// "Validation error" renvoyé par errorHandler.js.
const fieldErrors = e.details?.fieldErrors;
const detail = fieldErrors ? Object.values(fieldErrors).flat().filter(Boolean).join(' ') : '';
setRevisionErr(detail || e.message);
} finally { setRevisionSaving(false); }
};
const deleteRevision = async (rid) => {
try {
await api.del(`/investissements/${id}/revisions/${rid}`);
setConfirmingRevisionDelete(null);
await load();
} catch (e) { setConfirmingRevisionDelete(null); }
};
const openReinvModal = (tab = 'manuel') => {
setReinvForm({ montant: '', date_reinvestissement: today(), note: '' });
setReinvErr(null);
@@ -658,6 +699,11 @@ export default function InvestissementDetail() {
const simul = inv.simul || [];
const todayStr = today();
const historique = inv.historique || [];
const revisions = inv.revisions || [];
// Dernière révision ayant effectivement changé le taux / la date cible pour l'affichage
// "ancienne valeur barrée nouvelle valeur" dans Informations du projet.
const lastTauxRevision = [...revisions].reverse().find(r => String(r.ancien_taux) !== String(r.nouveau_taux));
const lastDateCibleRevision = [...revisions].reverse().find(r => String(r.ancienne_date_cible) !== String(r.nouvelle_date_cible));
const reinvs = inv.reinvestissements || [];
// Capital total = montant initial + réinvestissements
const capitalTotal = inv.capital_total ?? inv.montant_investi;
@@ -670,6 +716,32 @@ export default function InvestissementDetail() {
const interetsPercus = remb.reduce((s, r) => s + (r.interets_bruts || 0), 0);
const interetsPercusNet = remb.reduce((s, r) => s + (r.interets_nets || 0), 0);
// Taux annuel implicite calculé à partir des intérêts réellement versés à chaque échéance
// utile pour repérer une revalorisation de taux non annoncée par la plateforme.
// Base = capital restant dû juste avant le remboursement (montant initial + réinvestissements
// survenus avant ou à cette date, moins le capital déjà remboursé aux échéances précédentes).
// Non pertinent pour un prêt différé (un seul versement, pas de série à comparer).
const tauxImpliciteApplicable = inv.freq_interets === 'mensuel' || inv.freq_interets === 'trimestriel';
const tauxImpliciteByRembId = new Map();
if (tauxImpliciteApplicable) {
const rembTries = [...remb].sort((a, b) => a.date_remb.localeCompare(b.date_remb));
const reinvTries = [...reinvs].sort((a, b) => a.date_reinvestissement.localeCompare(b.date_reinvestissement));
const freqStep = inv.freq_interets === 'trimestriel' ? 3 : 1;
let reinvIdx = 0;
let capitalCourant = inv.montant_investi;
for (const r of rembTries) {
while (reinvIdx < reinvTries.length && reinvTries[reinvIdx].date_reinvestissement <= r.date_remb) {
capitalCourant += reinvTries[reinvIdx].montant;
reinvIdx++;
}
const base = capitalCourant;
const tauxBrut = base > 0 ? (r.interets_bruts || 0) / base * (12 / freqStep) * 100 : null;
const tauxNet = base > 0 ? (r.interets_nets || 0) / base * (12 / freqStep) * 100 : null;
tauxImpliciteByRembId.set(r.id, { base, tauxBrut, tauxNet });
capitalCourant -= (r.capital || 0);
}
}
// Rapprochement projections remboursements réels
// Priorité : date exacte, sinon même mois (YYYY-MM)
// Si tout le capital est remboursé (capRestant = 0), fallback sur le dernier remboursement
@@ -757,6 +829,15 @@ export default function InvestissementDetail() {
notes: h.notes,
created_at: h.created_at,
})),
revisions: (inv.revisions || []).map(r => ({
date_effet: r.date_effet,
ancien_taux: r.ancien_taux,
nouveau_taux: r.nouveau_taux,
ancienne_date_cible: r.ancienne_date_cible,
nouvelle_date_cible: r.nouvelle_date_cible,
motif: r.motif,
created_at: r.created_at,
})),
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
@@ -765,24 +846,34 @@ export default function InvestissementDetail() {
URL.revokeObjectURL(url);
};
// Rendement réel annualisé (XIRR) calculé uniquement si l'opération est remboursée
// Rendement annualisé (XIRR) calcul exact une fois le prêt intégralement remboursé.
// Sinon (en cours / en retard / procédure), estimation basée sur les remboursements déjà
// versés + une valorisation du capital restant dû à la date du jour (comme si ce capital
// était "racheté" aujourd'hui) donne un taux annualisé qui tient compte du rythme réel
// des versements, contrairement à un simple ratio intérêts perçus / capital / durée.
let rendementReelBrut = null;
let rendementReel = null;
if (inv.statut === 'rembourse' && remb.length > 0 && inv.date_souscription) {
const rendementEstime = inv.statut !== 'rembourse';
if (remb.length > 0 && inv.date_souscription) {
// Flux sortants supplémentaires (réinvestissements = nouveaux décaissements)
const reinvOutflows = reinvs.map(r => ({ amount: -r.montant, date: r.date_reinvestissement }));
// Valorisation à date du capital pas encore remboursé (uniquement pour l'estimation en cours)
const valorisationAuj = rendementEstime && capRestant > 0 ? [{ amount: capRestant, date: todayStr }] : [];
// Brut : capital + cashback + intérêts bruts (avant fiscalité)
const cashflowsBrut = [
{ amount: -inv.montant_investi, date: inv.date_souscription },
...reinvOutflows,
...remb.map(r => ({ amount: (r.capital || 0) + (r.cashback || 0) + (r.interets_bruts || 0), date: r.date_remb })),
...valorisationAuj,
];
rendementReelBrut = xirr(cashflowsBrut);
// Net : net_recu (après prélèvements sociaux + impôt sur le revenu)
// Net : net_recu (après prélèvements sociaux + impôt sur le revenu) le capital restant
// valorisé n'est, lui, pas soumis à fiscalité (ce n'est pas un gain, juste le principal)
const cashflows = [
{ amount: -inv.montant_investi, date: inv.date_souscription },
...reinvOutflows,
...remb.map(r => ({ amount: r.net_recu, date: r.date_remb })),
...valorisationAuj,
];
rendementReel = xirr(cashflows);
}
@@ -854,18 +945,34 @@ export default function InvestissementDetail() {
</div>
{(() => {
const rendement = netMode ? rendementReel : rendementReelBrut;
const titleTip = rendement !== null
? (netMode ? 'XIRR sur flux nets réels (après fiscalité)' : 'XIRR sur flux bruts (avant fiscalité)')
: inv.statut !== 'rembourse'
? 'Disponible uniquement sur les investissements remboursés'
: 'Données insuffisantes';
const xirrExplication =
`XIRR (taux de rendement interne actualisé) : taux annualisé calculé à partir des flux réels datés ` +
`(versement initial, réinvestissements, remboursements ${netMode ? 'nets, après fiscalité' : 'bruts, avant fiscalité'}). ` +
`Chaque flux est pondéré par sa date exacte — deux prêts au même total mais versés à des rythmes différents auront un XIRR différent.\n\n` +
(rendementEstime
? "Prêt pas encore soldé : le capital restant dû est ajouté en flux final, valorisé à la date du jour — c'est donc une estimation, pas un taux définitif."
: 'Prêt intégralement remboursé : taux définitif.');
return (
<div className="kpi" title={titleTip}>
<div className="label">Rendement annualisé {netMode ? 'Net' : 'Brut'}</div>
<div className="kpi">
<div className="label" style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span>
XIRR {netMode ? 'Net' : 'Brut'}
{rendementEstime && rendement !== null ? <span className="text-muted"> (estimé)</span> : ''}
</span>
<span
className="cell-tooltip tooltip-down"
data-tooltip={xirrExplication}
style={{ display: 'inline-flex', color: 'var(--text-muted)' }}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/>
</svg>
</span>
</div>
<div className="value" style={{ color: rendement !== null ? (rendement >= 0 ? 'var(--success)' : 'var(--danger)') : 'var(--text-muted)' }}>
{rendement !== null
? `${(rendement * 100).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} %`
: inv.statut === 'rembourse' ? '—' : 'En cours…'}
: remb.length === 0 ? 'Aucun remboursement' : '—'}
</div>
</div>
);
@@ -935,7 +1042,21 @@ export default function InvestissementDetail() {
)}
<div>
<div className="text-muted" style={{ fontSize: 11, marginBottom: 2 }}>Taux annuel</div>
<div>{inv.taux_interet != null ? fmtPct(inv.taux_interet) : '—'}</div>
{lastTauxRevision ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<span style={{ textDecoration: 'line-through', opacity: 0.6 }}>{fmtPct(lastTauxRevision.ancien_taux)}</span>
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}></span>
<span style={{ fontWeight: 600 }}>{inv.taux_interet != null ? fmtPct(inv.taux_interet) : '—'}</span>
<span title={lastTauxRevision.motif} style={{
fontSize: 10, fontWeight: 600, color: '#a78bfa',
background: 'rgba(167,139,250,0.15)', borderRadius: 4, padding: '1px 6px',
}}>
Révisé le {fmtDate(lastTauxRevision.date_effet)}
</span>
</div>
) : (
<div>{inv.taux_interet != null ? fmtPct(inv.taux_interet) : '—'}</div>
)}
</div>
<div>
<div className="text-muted" style={{ fontSize: 11, marginBottom: 2 }}>Durée</div>
@@ -951,7 +1072,21 @@ export default function InvestissementDetail() {
</div>
<div>
<div className="text-muted" style={{ fontSize: 11, marginBottom: 2 }}>Date cible contractuelle</div>
<div>{fmtDate(inv.date_cible) || '—'}</div>
{lastDateCibleRevision ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<span style={{ textDecoration: 'line-through', opacity: 0.6 }}>{fmtDate(lastDateCibleRevision.ancienne_date_cible) || '—'}</span>
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}></span>
<span style={{ fontWeight: 600 }}>{fmtDate(inv.date_cible) || '—'}</span>
<span title={lastDateCibleRevision.motif} style={{
fontSize: 10, fontWeight: 600, color: '#a78bfa',
background: 'rgba(167,139,250,0.15)', borderRadius: 4, padding: '1px 6px',
}}>
Révisé le {fmtDate(lastDateCibleRevision.date_effet)}
</span>
</div>
) : (
<div>{fmtDate(inv.date_cible) || '—'}</div>
)}
</div>
{inv.notes && (
<div style={{ gridColumn: '1 / -1' }}>
@@ -969,6 +1104,107 @@ export default function InvestissementDetail() {
</div>
</div>
{/* ── Révisions des conditions de prêt ──────────────────────── */}
{revisions.length > 0 && (
<div className="card" style={{ marginBottom: 16 }}>
<h3 style={{ marginTop: 0, marginBottom: 16 }}>Révisions du prêt</h3>
<div style={{ position: 'relative', paddingLeft: 28 }}>
<div style={{
position: 'absolute', left: 9, top: 0, bottom: 0,
width: 2, background: 'var(--border, rgba(255,255,255,.1))',
}} />
{[...revisions].reverse().map((rev, idx) => {
const isLast = idx === 0; // dernière révision (liste inversée) = seule supprimable
const isConfirmingDelete = confirmingRevisionDelete === rev.id;
const tauxChange = String(rev.ancien_taux) !== String(rev.nouveau_taux);
const dateChange = String(rev.ancienne_date_cible) !== String(rev.nouvelle_date_cible);
return (
<div key={rev.id} style={{ position: 'relative', marginBottom: idx < revisions.length - 1 ? 16 : 0 }}>
<div style={{
position: 'absolute', left: -24, top: 14,
width: 10, height: 10, borderRadius: '50%',
background: '#a78bfa',
border: '2px solid var(--bg-card, #1e293b)',
zIndex: 1,
}} />
<div style={{
background: 'rgba(167,139,250,0.07)',
border: isConfirmingDelete ? '1px solid rgba(239,68,68,0.5)' : '1px solid rgba(167,139,250,0.3)',
borderRadius: 8, padding: '10px 14px',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8 }}>
<span style={{ fontWeight: 700, fontSize: 13, color: '#a78bfa' }}> Révision des conditions</span>
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>Effet au {fmtDate(rev.date_effet)}</span>
{isLast && !isConfirmingDelete && (
<button
title="Supprimer cette révision (rollback)"
onClick={() => setConfirmingRevisionDelete(rev.id)}
style={{
marginLeft: 'auto', background: 'transparent', border: 'none',
cursor: 'pointer', padding: '2px 6px', borderRadius: 4,
color: 'var(--text-muted)', fontSize: 14, lineHeight: 1,
}}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}
>
🗑
</button>
)}
</div>
{isConfirmingDelete && (
<div style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '8px 12px', marginBottom: 10,
background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.3)', borderRadius: 6,
}}>
<span style={{ fontSize: 16 }}></span>
<span style={{ fontSize: 12, flex: 1, color: 'var(--text)', lineHeight: 1.4 }}>
Annuler cette révision ?<br />
<span style={{ color: 'var(--text-muted)', fontSize: 11 }}>
Les conditions reviendront à l'état précédent et l'échéancier sera régénéré.
</span>
</span>
<button onClick={() => setConfirmingRevisionDelete(null)} style={{ fontSize: 12, padding: '4px 10px' }}>Annuler</button>
<button className="danger" onClick={() => deleteRevision(rev.id)} style={{ fontSize: 12, padding: '4px 10px' }}>Confirmer</button>
</div>
)}
{tauxChange && (
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, flexWrap: 'wrap', fontSize: 12, padding: '3px 0' }}>
<span style={{ color: 'var(--text-muted)', minWidth: 100 }}>Taux annuel</span>
<span style={{ background: 'rgba(239,68,68,0.12)', color: '#f87171', borderRadius: 4, padding: '1px 6px', textDecoration: 'line-through', opacity: 0.8 }}>
{rev.ancien_taux != null ? fmtPct(rev.ancien_taux) : '—'}
</span>
<span style={{ color: 'var(--text-muted)', fontSize: 10 }}></span>
<span style={{ background: 'rgba(34,197,94,0.12)', color: '#4ade80', borderRadius: 4, padding: '1px 6px' }}>
{rev.nouveau_taux != null ? fmtPct(rev.nouveau_taux) : '—'}
</span>
</div>
)}
{dateChange && (
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, flexWrap: 'wrap', fontSize: 12, padding: '3px 0' }}>
<span style={{ color: 'var(--text-muted)', minWidth: 100 }}>Date cible</span>
<span style={{ background: 'rgba(239,68,68,0.12)', color: '#f87171', borderRadius: 4, padding: '1px 6px', textDecoration: 'line-through', opacity: 0.8 }}>
{fmtDate(rev.ancienne_date_cible) || '—'}
</span>
<span style={{ color: 'var(--text-muted)', fontSize: 10 }}></span>
<span style={{ background: 'rgba(34,197,94,0.12)', color: '#4ade80', borderRadius: 4, padding: '1px 6px' }}>
{fmtDate(rev.nouvelle_date_cible) || '—'}
</span>
</div>
)}
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--text)', lineHeight: 1.4 }}>
<span className="text-muted">Motif : </span>{rev.motif}
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{/* ── Remboursements enregistrés ────────────────────────────── */}
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
@@ -1036,7 +1272,17 @@ export default function InvestissementDetail() {
{plats.find(p => p.id === inv?.plateforme_id)?.fiscalite === 'avec_fiscalite_locale' && plats.find(p => p.id === inv?.plateforme_id)?.taux_fiscalite_locale && (
<td className="num">{r.taxe_locale ? fmtEUR(r.taxe_locale) : '—'}</td>
)}
<td className="num">{fmtEUR(netMode ? r.interets_nets : r.interets_bruts)}</td>
<td
className={tauxImpliciteApplicable ? 'num cell-tooltip' : 'num'}
data-tooltip={(() => {
if (!tauxImpliciteApplicable) return undefined;
const ti = tauxImpliciteByRembId.get(r.id);
if (!ti || !(ti.base > 0)) return 'Taux implicite non calculable (capital restant nul ou inconnu)';
return `Taux annuel implicite (calculé sur ce versement)\nBrut : ${fmtPct(ti.tauxBrut)}\nNet : ${fmtPct(ti.tauxNet)}\nBase (capital restant dû) : ${fmtEUR(ti.base)}`;
})()}
>
{fmtEUR(netMode ? r.interets_nets : r.interets_bruts)}
</td>
<td className="num">{fmtEUR(r.cashback)}</td>
<td className="num cell-tooltip"
data-tooltip={`Prélèvements sociaux : ${fmtEUR(r.prelev_sociaux)}\nImpôt sur le revenu : ${fmtEUR(r.prelev_forfaitaire)}`}>
@@ -1468,6 +1714,97 @@ export default function InvestissementDetail() {
</div>
)}
{/* ── Modal révision des conditions de prêt ─────────────────── */}
<Modal
open={revisionModalOpen}
title="Réviser les conditions du prêt"
onClose={closeRevisionModal}
width={520}
footer={
<>
<button className="ghost" onClick={closeRevisionModal}>Annuler</button>
<button onClick={submitRevision} disabled={revisionSaving}>
{revisionSaving ? 'Enregistrement…' : 'Enregistrer la révision'}
</button>
</>
}
>
<p className="text-muted" style={{ fontSize: 13, marginTop: 0 }}>
À utiliser lorsqu'un événement (retard projet, renégociation) modifie le taux et/ou la date cible
du prêt. L'échéancier de projection sera régénéré automatiquement à partir de la date d'effet.
</p>
<form onSubmit={submitRevision}>
<label>Date d'effet</label>
<input
type="date"
value={revisionForm.date_effet}
onChange={e => setRevisionForm(f => ({ ...f, date_effet: e.target.value }))}
required
/>
<label style={{
marginTop: 12, display: 'flex', alignItems: 'center', gap: 8,
cursor: 'pointer', userSelect: 'none', fontWeight: 400,
}}>
<input
type="checkbox"
style={{ width: 'auto' }}
checked={revisionForm.changeTaux}
onChange={e => {
const checked = e.target.checked;
setRevisionForm(f => ({ ...f, changeTaux: checked, nouveau_taux: checked ? f.nouveau_taux : '' }));
}}
/>
Modifier le taux annuel <span className="text-muted"> actuel : {inv.taux_interet != null ? fmtPct(inv.taux_interet) : '—'}</span>
</label>
{revisionForm.changeTaux && (
<input
type="number" step="0.01" min="0" required
placeholder="Nouveau taux (%)"
style={{ marginTop: 6 }}
value={revisionForm.nouveau_taux}
onChange={e => setRevisionForm(f => ({ ...f, nouveau_taux: e.target.value }))}
/>
)}
<label style={{
marginTop: 12, display: 'flex', alignItems: 'center', gap: 8,
cursor: 'pointer', userSelect: 'none', fontWeight: 400,
}}>
<input
type="checkbox"
style={{ width: 'auto' }}
checked={revisionForm.changeDate}
onChange={e => {
const checked = e.target.checked;
setRevisionForm(f => ({ ...f, changeDate: checked, nouvelle_date_cible: checked ? f.nouvelle_date_cible : '' }));
}}
/>
Modifier la date cible <span className="text-muted"> actuelle : {fmtDate(inv.date_cible) || '—'}</span>
</label>
{revisionForm.changeDate && (
<>
<input
type="date" required
style={{ marginTop: 6 }}
value={revisionForm.nouvelle_date_cible}
onChange={e => setRevisionForm(f => ({ ...f, nouvelle_date_cible: e.target.value }))}
/>
<div className="text-muted" style={{ fontSize: 11, marginTop: 4 }}>
La durée du prêt sera recalculée automatiquement pour que l'échéancier atteigne cette date.
</div>
</>
)}
<label style={{ marginTop: 10, display: 'block' }}>Motif de la révision</label>
<textarea
rows={3}
placeholder="Ex : retard du projet, renégociation à la baisse suite à difficultés de l'émetteur…"
value={revisionForm.motif}
onChange={e => setRevisionForm(f => ({ ...f, motif: e.target.value }))}
required
/>
{revisionErr && <div className="error" style={{ marginTop: 10 }}>{revisionErr}</div>}
</form>
</Modal>
{/* ── Modal édition ─────────────────────────────────────────── */}
<Modal
open={modalOpen}
@@ -1662,6 +1999,12 @@ export default function InvestissementDetail() {
<div>
<label>Taux annuel (%)</label>
<input type="number" step="0.01" value={form.taux_interet} onChange={e => setForm({ ...form, taux_interet: e.target.value })} />
{lastTauxRevision && (
<div style={{ fontSize: 11, color: '#a78bfa', marginTop: 4, lineHeight: 1.4 }}>
Taux révisé le {fmtDate(lastTauxRevision.date_effet)}. Une modification ici ne sera pas tracée
comme une révision utilisez « Réviser les conditions » pour un changement lié à un événement.
</div>
)}
</div>
<div>
<label>Montant investi () *</label>
@@ -1762,6 +2105,12 @@ export default function InvestissementDetail() {
setForm({ ...form, date_cible: e.target.value });
}}
/>
{lastDateCibleRevision && (
<div style={{ fontSize: 11, color: '#a78bfa', marginTop: 4, lineHeight: 1.4 }}>
Date cible révisée le {fmtDate(lastDateCibleRevision.date_effet)}. Une modification ici ne sera pas tracée
comme une révision utilisez « Réviser les conditions » pour un changement lié à un événement.
</div>
)}
</div>
{/* Champ restructuration : visible uniquement si des remboursements existent
ET que le type de prêt a été modifié (ou qu'une date était déjà définie) */}
@@ -2260,7 +2609,7 @@ export default function InvestissementDetail() {
</svg>
Traitement en masse des remboursements
</button>
{inv.taux_interet && inv.duree_mois ? (
{inv.taux_interet != null && inv.duree_mois ? (
<button
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: recalculating ? 'not-allowed' : 'pointer', fontSize: 'var(--fs-sm)', color: recalculating ? 'var(--text-muted)' : 'var(--text)', textAlign: 'left', opacity: recalculating ? 0.6 : 1 }}
onMouseEnter={e => { if (!recalculating) e.currentTarget.style.background = 'var(--surface-2)'; }}
@@ -2410,6 +2759,7 @@ export default function InvestissementDetail() {
{[
{ label: 'Modifier', icon: <><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></>, action: () => { setCardMenu(null); openEdit(); } },
{ label: 'Réinvestir', icon: <><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></>, action: () => { setCardMenu(null); openReinvModal(); } },
{ label: 'Réviser les conditions', icon: <><path d="M21 12a9 9 0 1 1-2.64-6.36"/><polyline points="21 3 21 9 15 9"/></>, action: () => { setCardMenu(null); openRevisionModal(); } },
{ label: 'Exporter', icon: <><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></>, action: () => { setCardMenu(null); exportDossier(); } },
].map(({ label, icon, action }) => (
<button key={label}
+19 -7
View File
@@ -779,6 +779,18 @@ export default function Investissements() {
<EmptyState />
</>
);
if (!loading && !modalOpen && allRows.length === 0) return (
<>
<div className="topbar"><h2>Investissements</h2></div>
<EmptyState
icon="📊"
title="Aucun investissement pour le moment"
message="Vos plateformes sont configurées, mais aucun investissement n'y a encore été ajouté. Ajoutez votre premier investissement pour commencer le suivi."
cta="Ajouter un investissement"
to="/investissements?new=1"
/>
</>
);
return (
<>
<div className="topbar">
@@ -950,7 +962,7 @@ export default function Investissements() {
<thead>
<tr>
<th>Plateforme</th>
<th>Détenteur</th>
{multiDetenteur && <th>Détenteur</th>}
<th className="num">Projets</th>
<th className="num">Montant investi</th>
<th className="num">Capital investi</th>
@@ -962,7 +974,7 @@ export default function Investissements() {
</thead>
<tbody>
{platData.length === 0 && (
<tr><td colSpan={9} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
<tr><td colSpan={multiDetenteur ? 9 : 8} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
Aucun investissement
</td></tr>
)}
@@ -987,7 +999,7 @@ export default function Investissements() {
}}>!</span>
)}
</td>
<td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{p.detenteur_nom || '—'}</td>
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{p.detenteur_nom || '—'}</td>}
<td className="num">{p.count}</td>
<td className="num">{fmtEUR(p.investi)}</td>
<td className="num">{fmtEUR(p.encours)}</td>
@@ -1020,7 +1032,7 @@ export default function Investissements() {
<td style={{ padding: '10px 12px', color: 'var(--text-muted)', fontSize: 'var(--fs-sm)', fontWeight: 600 }}>
Total {platData.length} plateformes
</td>
<td />
{multiDetenteur && <td />}
<td className="num" style={{ padding: '10px 12px' }}>{platTotals.count}</td>
<td className="num" style={{ padding: '10px 12px' }}>{fmtEUR(platTotals.investi)}</td>
<td className="num" style={{ padding: '10px 12px' }}>{fmtEUR(platTotals.encours)}</td>
@@ -1190,7 +1202,7 @@ export default function Investissements() {
<table style={{ cursor: 'pointer' }}>
<thead>
<tr>
<th>Date</th><th>Projet</th><th>Plateforme</th><th>Détenteur</th>
<th>Date</th><th>Projet</th><th>Plateforme</th>{multiDetenteur && <th>Détenteur</th>}
<th className="num">Montant</th><th className="num">Taux</th>
<th className="num">Durée</th><th>Type remb.</th>
<th className="num">Inv. remboursés</th><th className="num">Cap. restant </th>
@@ -1201,7 +1213,7 @@ export default function Investissements() {
</thead>
<tbody>
{rows.length === 0 && (
<tr><td colSpan={13} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
<tr><td colSpan={multiDetenteur ? 13 : 12} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
{loading ? 'Chargement…' : 'Aucun investissement'}
</td></tr>
)}
@@ -1221,7 +1233,7 @@ export default function Investissements() {
{r.emetteur && <div className="text-muted" style={{ fontSize: 11 }}>{r.emetteur}</div>}
</td>
<td>{r.plateforme_nom}</td>
<td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{r.plateforme_detenteur_nom || '—'}</td>
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{r.plateforme_detenteur_nom || '—'}</td>}
<td className="num">{fmtEUR(capInv)}</td>
<td className="num">{r.taux_interet != null ? fmtPct(r.taux_interet) : '—'}</td>
<td className="num">{r.duree_mois ? `${r.duree_mois} m.` : '—'}</td>
+3 -2
View File
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import PasswordStrength from '../components/PasswordStrength.jsx';
import PasswordInput from '../components/PasswordInput.jsx';
import { useParams, useNavigate, Link } from 'react-router-dom';
// Wrap défini en dehors du composant pour éviter le remontage à chaque frappe
@@ -173,13 +174,13 @@ export default function InvitationRegister() {
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>Mot de passe <span style={{ color: 'var(--text-muted)', fontWeight: 400 }}>(8 car. min.)</span></label>
<input className="form-input" type="password" required minLength={appInfo.minPasswordLength || 8} autoComplete="new-password" placeholder="••••••••" value={form.password} onChange={e => set('password', e.target.value)} style={{ width: '100%' }} />
<PasswordInput className="form-input" required minLength={appInfo.minPasswordLength || 8} autoComplete="new-password" placeholder="••••••••" value={form.password} onChange={e => set('password', e.target.value)} style={{ width: '100%' }} />
<PasswordStrength password={form.password} minLength={appInfo.minPasswordLength || 8} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>Confirmer le mot de passe</label>
<input className="form-input" type="password" required minLength={appInfo.minPasswordLength || 8} autoComplete="new-password" placeholder="••••••••" value={form.confirm} onChange={e => set('confirm', e.target.value)} style={{ width: '100%' }} />
<PasswordInput className="form-input" required minLength={appInfo.minPasswordLength || 8} autoComplete="new-password" placeholder="••••••••" value={form.confirm} onChange={e => set('confirm', e.target.value)} style={{ width: '100%' }} />
</div>
<button type="submit" disabled={busy} style={{ marginTop: 4, width: '100%', padding: '11px 0', background: busy ? 'var(--text-muted)' : 'var(--primary, #1e40af)', color: '#fff', border: 'none', borderRadius: 8, fontSize: 15, fontWeight: 600, cursor: busy ? 'not-allowed' : 'pointer', transition: 'background 0.15s' }}>
+11 -2
View File
@@ -1,8 +1,9 @@
import { useState, useEffect, useRef } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '../context/AuthContext.jsx';
import { api } from '../api.js';
import AuthBgCol from '../components/AuthBgCol.jsx';
import PasswordInput from '../components/PasswordInput.jsx';
// Helpers
const DEVICE_KEY = 'cl_device_token';
@@ -27,6 +28,8 @@ function AppHeader({ appInfo }) {
export default function Login() {
const { login, completeLogin } = useAuth();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const accountDeleted = searchParams.get('deleted') === '1';
const [appInfo, setAppInfo] = useState({ appName: 'Crowdlending Tracker', iconUrl: null, allowRegistration: true });
@@ -192,6 +195,12 @@ export default function Login() {
<form onSubmit={submit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<ErrBox msg={err} />
{accountDeleted && !err && (
<div style={{ padding: '10px 14px', borderRadius: 8, fontSize: 14, background: 'var(--success-bg, #f0fdf4)', color: 'var(--success, #16a34a)', border: '1px solid #bbf7d0' }}>
Votre compte a été supprimé avec succès.
</div>
)}
{unverified && (
<div style={{ padding: '12px 14px', borderRadius: 8, fontSize: 13, background: 'var(--warning-bg, #fffbeb)', color: 'var(--warning-text, #92400e)', border: '1px solid var(--warning-border, #fcd34d)', lineHeight: 1.5 }}>
<strong>Email non vérifié.</strong> Vérifiez votre boîte mail et cliquez sur le lien reçu.
@@ -217,7 +226,7 @@ export default function Login() {
<label style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>Mot de passe</label>
<Link to="/forgot-password" style={{ fontSize: 13, color: 'var(--text-muted)', textDecoration: 'underline' }}>Mot de passe oublié ?</Link>
</div>
<input className="form-input" type="password" required autoComplete="current-password" placeholder="••••••••"
<PasswordInput className="form-input" required autoComplete="current-password" placeholder="••••••••"
value={password} onChange={e => setPassword(e.target.value)} style={{ width: '100%' }} />
</div>
File diff suppressed because it is too large Load Diff
+122 -5
View File
@@ -7,7 +7,9 @@ import PageIcon from '../components/PageIcon.jsx';
import EmptyState from '../components/EmptyState.jsx';
import InvChart from '../components/InvChart.jsx';
import InvMensuelTable from '../components/InvMensuelTable.jsx';
import { fmtEUR, fmtDate, fmtStatut, memberLabel } from '../utils/format.js';
import SuiviObjectifs from '../components/SuiviObjectifs.jsx';
import { fmtEUR, fmtDate, fmtStatut, memberLabel, today } from '../utils/format.js';
import { xirr } from '../utils/xirr.js';
import { usePagination } from '../hooks/usePagination.js';
import Pagination from '../components/Pagination.jsx';
@@ -424,6 +426,9 @@ export default function Plateformes() {
];
}, [platRows, investisseurs]);
/* ── Vrai/faux multi-détenteur global (indépendant de la plateforme sélectionnée) ── */
const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1;
/* ── Reset détenteur quand plateforme change (skip restauration initiale) ── */
const isFirstPlatChange = useRef(true);
useEffect(() => {
@@ -594,16 +599,76 @@ export default function Plateformes() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedYear, filteredRows, allRembs, allReinvests, lastRembDateMap]);
/* XIRR agrégé (flux datés : investis + réinvestissements + remboursements + valorisation
du capital restant pour les prêts non soldés, à la date de coupure) */
const plateformeXirr = useMemo(() => {
const cutoff = selectedYear ? `${selectedYear}-12-31` : today();
const ids = new Set(chartRows.map(r => r.id));
const flowsBrut = [];
const flowsNet = [];
for (const r of chartRows) {
if (!r.date_souscription) continue;
flowsBrut.push({ amount: -r.montant_investi, date: r.date_souscription });
flowsNet.push({ amount: -r.montant_investi, date: r.date_souscription });
}
for (const rv of allReinvests) {
if (!ids.has(rv.investissement_id)) continue;
const d = rv.date_reinvestissement?.slice(0, 10);
if (!d || d > cutoff) continue;
flowsBrut.push({ amount: -(rv.montant || 0), date: d });
flowsNet.push({ amount: -(rv.montant || 0), date: d });
}
for (const rb of allRembs) {
if (!ids.has(rb.investissement_id)) continue;
const d = rb.date_remb?.slice(0, 10);
if (!d || d > cutoff) continue;
flowsBrut.push({ amount: (rb.capital || 0) + (rb.cashback || 0) + (rb.interets_bruts || 0), date: d });
flowsNet.push({ amount: rb.net_recu || 0, date: d });
}
let estime = false;
for (const r of chartRows) {
const capInv = r.montant_investi + (reinvestCumulParInv[r.id] || 0);
const capRemb = capRembParInv[r.id] || 0;
const capRestant = Math.max(0, capInv - capRemb);
if (capRestant > 0.01) {
estime = true;
flowsBrut.push({ amount: capRestant, date: cutoff });
flowsNet.push({ amount: capRestant, date: cutoff });
}
}
const byDate = (a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0);
flowsBrut.sort(byDate);
flowsNet.sort(byDate);
return { brut: xirr(flowsBrut), net: xirr(flowsNet), estime, cutoff };
}, [chartRows, allRembs, allReinvests, selectedYear, reinvestCumulParInv, capRembParInv]);
const netMode = displayMode === 'net';
const prevYearLabel = selectedYear ? Number(selectedYear) - 1 : new Date().getFullYear() - 1;
if (!loading && plats.length === 0) {
return (
<>
<div className="topbar">
<h2><PageIcon name="plateforme" />Plateformes</h2>
</div>
<EmptyState />
</>
);
}
if (!loading && platOptions.length === 0) {
return (
<>
<div className="topbar">
<h2><PageIcon name="plateforme" />Plateformes</h2>
</div>
<EmptyState />
<EmptyState
icon="📊"
title="Aucun investissement pour le moment"
message="Vos plateformes sont configurées, mais aucun investissement n'y a encore été ajouté. Ajoutez un investissement pour voir apparaître les données de vos plateformes."
cta="Ajouter un investissement"
to="/investissements?new=1"
/>
</>
);
}
@@ -648,7 +713,7 @@ export default function Plateformes() {
</div>}
{/* ── KPIs ── */}
{!listFocused && <div className="dr-kpi-row" style={{ gridTemplateColumns: 'repeat(5, 1fr)' }}>
{!listFocused && <div className="dr-kpi-row" style={{ gridTemplateColumns: 'repeat(6, 1fr)' }}>
{/* 1 — Capital investi */}
<div className="kpi">
@@ -716,6 +781,50 @@ export default function Plateformes() {
{fmtEUR(netMode ? prevTotals.int_perc_net : prevTotals.int_perc)} en {prevYearLabel}
</div>
</div>
{/* 6 — XIRR */}
{(() => {
const rendement = netMode ? plateformeXirr.net : plateformeXirr.brut;
const cutoffLabel = selectedYear ? `31/12/${selectedYear}` : "aujourd'hui";
const xirrExplication =
`XIRR (taux de rendement interne actualisé) : taux annualisé calculé à partir de l'ensemble des flux réels datés ` +
`des prêts de cette sélection (versements initiaux, réinvestissements, remboursements ${netMode ? 'nets, après fiscalité' : 'bruts, avant fiscalité'}). ` +
`Chaque flux est pondéré par sa date exacte.\n\n` +
(plateformeXirr.estime
? `Certains prêts ne sont pas encore soldés : leur capital restant dû est ajouté en flux final, valorisé au ${cutoffLabel} — c'est donc une estimation, pas un taux définitif.`
: 'Tous les prêts de cette sélection sont soldés : taux définitif.');
return (
<div className="kpi">
<div className="label" style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span>
XIRR {netMode ? 'Net' : 'Brut'}
{plateformeXirr.estime && rendement !== null ? <span className="text-muted"> (estimé)</span> : ''}
</span>
<span
className="cell-tooltip tooltip-down"
data-tooltip={xirrExplication}
style={{ display: 'inline-flex', color: 'var(--text-muted)' }}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/>
</svg>
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
<span
style={{ fontSize: '1.35rem', fontWeight: 700, color: rendement !== null ? (rendement >= 0 ? 'var(--success)' : 'var(--danger)') : 'var(--text-muted)' }}
>
{rendement !== null
? `${(rendement * 100).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} %`
: 'Aucun remboursement'}
</span>
</div>
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>
Valorisé au {cutoffLabel}
</div>
</div>
);
})()}
</div>}
{/* ── Onglets ── */}
@@ -1402,6 +1511,14 @@ export default function Plateformes() {
</div>
);
})()}
{/* Suivi des objectifs de versement toujours sur le portefeuille entier,
indépendamment du filtre plateforme de cette page (cf. décision produit). */}
<SuiviObjectifs
rows={allDepots}
investisseurs={investisseurs}
scopeInvestisseurIds={activeView === 'all' ? investisseurs.map(i => i.id) : [activeId]}
/>
</div>
</div>
);
@@ -1517,7 +1634,7 @@ export default function Plateformes() {
<thead>
<tr>
<th>Projet</th>
<th>Détenteur</th>
{multiDetenteur && <th>Détenteur</th>}
<th className="num">Date de souscription</th>
<th className="num">Date cible</th>
<th className="num">Montant</th>
@@ -1538,7 +1655,7 @@ export default function Plateformes() {
<tr key={r.id} style={{ cursor: 'pointer' }}
onClick={() => navigate(`/investissements/${r.id}`, { state: { from: { path: location.pathname, search: location.search, label: 'Plateformes' } } })}>
<td style={{ fontWeight: 500 }}>{r.nom_projet}</td>
<td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{(() => { const inv = investisseurs.find(i => i.id === r.investisseur_id); return inv ? memberLabel(inv) : '—'; })()}</td>
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{(() => { const inv = investisseurs.find(i => i.id === r.investisseur_id); return inv ? memberLabel(inv) : '—'; })()}</td>}
<td className="num" style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>{fmtDate(r.date_souscription)}</td>
<td className="num" style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>{fmtDate(r.date_cible)}</td>
<td className="num">{fmtEUR(r.montant_investi)}</td>
+3 -2
View File
@@ -3,6 +3,7 @@ import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext.jsx';
import PasswordStrength from '../components/PasswordStrength.jsx';
import AuthBgCol from '../components/AuthBgCol.jsx';
import PasswordInput from '../components/PasswordInput.jsx';
export default function Register() {
const { register } = useAuth();
@@ -178,9 +179,9 @@ export default function Register() {
<label style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>
Mot de passe
</label>
<input
<PasswordInput
className="form-input"
type="password" required
required
minLength={appInfo.minPasswordLength || 8}
autoComplete="new-password"
placeholder="••••••••"
+24 -10
View File
@@ -768,6 +768,18 @@ export default function Remboursements() {
<EmptyState />
</>
);
if (platLoaded && !loading && !modalOpen && allRows.length === 0) return (
<>
<div className="topbar"><h2>Remboursements</h2></div>
<EmptyState
icon="📋"
title="Aucun remboursement pour le moment"
message="Vos plateformes sont configurées, mais aucun remboursement n'a encore été enregistré. Ajoutez un remboursement pour voir apparaître vos données."
cta="Ajouter un remboursement"
to="/remboursements?new=1"
/>
</>
);
return (
<>
<div className="topbar">
@@ -887,7 +899,7 @@ export default function Remboursements() {
<thead>
<tr>
<th>Plateforme</th>
<th>Détenteur</th>
{multiDetenteur && <th>Détenteur</th>}
<th className="num">Capital remboursé</th>
<th className="num">Cashback</th>
<th className="num">Intérêts ({netMode ? 'Net' : 'Brut'})</th>
@@ -899,7 +911,7 @@ export default function Remboursements() {
</thead>
<tbody>
{byPlatform.length === 0 && (
<tr><td colSpan={9} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
<tr><td colSpan={multiDetenteur ? 9 : 8} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
{loading ? 'Chargement…' : 'Aucun remboursement'}
</td></tr>
)}
@@ -915,7 +927,7 @@ export default function Remboursements() {
title={isActive ? 'Cliquer pour retirer le filtre' : 'Cliquer pour filtrer sur cette plateforme'}
>
<td><span style={{ fontWeight: isActive ? 600 : undefined }}>{p.nom}</span></td>
<td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{p.detenteur_nom || '—'}</td>
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{p.detenteur_nom || '—'}</td>}
<td className="num">{fmtEUR(p.capital)}</td>
<td className="num">{p.cashback > 0 ? fmtEUR(p.cashback) : '—'}</td>
<td className="num">{fmtEUR(val)}</td>
@@ -942,7 +954,7 @@ export default function Remboursements() {
<tfoot>
<tr style={{ borderTop: '2px solid var(--border)', fontWeight: 700, background: 'var(--surface-2)' }}>
<td>Total {byPlatform.length} plateformes</td>
<td />
{multiDetenteur && <td />}
<td className="num">{fmtEUR(byPlatformTotals.capital)}</td>
<td className="num">{byPlatformTotals.cashback > 0 ? fmtEUR(byPlatformTotals.cashback) : '—'}</td>
<td className="num">{fmtEUR(netMode ? byPlatformTotals.interets_nets : byPlatformTotals.interets_bruts)}</td>
@@ -1119,7 +1131,7 @@ export default function Remboursements() {
<table>
<thead>
<tr>
<th>Date</th><th>Plateforme</th><th>Détenteur</th><th>Nom du projet</th><th>Versement</th>
<th>Date</th><th>Plateforme</th>{multiDetenteur && <th>Détenteur</th>}<th>Nom du projet</th><th>Versement</th>
<th className="num">Capital Remboursé</th><th className="num">Capital Restant </th>
<th className="num">Cashback</th>
<th className="num">Intérêts Bruts</th>
@@ -1131,7 +1143,7 @@ export default function Remboursements() {
</thead>
<tbody>
{tableRows.length === 0 && (
<tr><td colSpan={14} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
<tr><td colSpan={multiDetenteur ? 14 : 13} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
{loading ? 'Chargement…' : 'Aucun remboursement'}
</td></tr>
)}
@@ -1157,9 +1169,11 @@ export default function Remboursements() {
}}>
<td>{fmtDate(r.date_remb)}</td>
<td>{r.plateforme_nom}</td>
<td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>
{isCorrection ? (r.investisseur_nom || '—') : (r.plateforme_detenteur_nom || '—')}
</td>
{multiDetenteur && (
<td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>
{isCorrection ? (r.investisseur_nom || '—') : (r.plateforme_detenteur_nom || '—')}
</td>
)}
<td>
{isCorrection
? <span style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>{r.nom_projet}</span>
@@ -1272,7 +1286,7 @@ export default function Remboursements() {
{simulMsg}
</div>
)}
{simulInv && (!simulInv.taux_interet || !simulInv.duree_mois) && (
{simulInv && (simulInv.taux_interet == null || !simulInv.duree_mois) && (
<div className="error" style={{ marginTop: 12 }}>
Cet investissement n'a pas de <strong>taux</strong> et/ou de <strong>durée</strong>. Renseignez-les dans la fiche.
</div>
+5 -4
View File
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import PasswordStrength from '../components/PasswordStrength.jsx';
import AuthBgCol from '../components/AuthBgCol.jsx';
import PasswordInput from '../components/PasswordInput.jsx';
export default function ResetPassword() {
const [params] = useSearchParams();
@@ -154,9 +155,9 @@ export default function ResetPassword() {
<label style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>
Nouveau mot de passe
</label>
<input
<PasswordInput
className="form-input"
type="password" required minLength={appInfo.minPasswordLength || 8}
required minLength={appInfo.minPasswordLength || 8}
autoComplete="new-password"
placeholder="••••••••"
value={password}
@@ -170,9 +171,9 @@ export default function ResetPassword() {
<label style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>
Confirmer le mot de passe
</label>
<input
<PasswordInput
className="form-input"
type="password" required minLength={appInfo.minPasswordLength || 8}
required minLength={appInfo.minPasswordLength || 8}
autoComplete="new-password"
placeholder="••••••••"
value={password2}
+1 -1
View File
@@ -69,7 +69,7 @@ export default function SimulRemboursements() {
</div>
</div>
{msg && <div className={msg.startsWith('✔') ? 'success-msg' : 'error'} style={{ marginTop: 12 }}>{msg}</div>}
{inv && (!inv.taux_interet || !inv.duree_mois) && (
{inv && (inv.taux_interet == null || !inv.duree_mois) && (
<div className="error" style={{ marginTop: 12 }}>
Cet investissement n'a pas de <strong>taux</strong> et/ou de <strong>durée</strong>. Renseignez-les dans la fiche.
</div>
+21 -5
View File
@@ -156,6 +156,12 @@ export default function TaxReport() {
const [activeTab, setActiveTab] = useState('2042');
const [detailExpanded, setDetailExpanded] = useState(false);
const [cerfaExpanded, setCerfaExpanded] = useState(false);
const [hasPlateformes, setHasPlateformes] = useState(true); // true par défaut pour éviter un flash du mauvais message
/* ── Plateformes configurées ? (pour distinguer "pas de plateforme" de "pas encore de données") ── */
useEffect(() => {
api.get('/plateformes').then(p => setHasPlateformes((p || []).length > 0)).catch(() => {});
}, []);
/* ── Chargement des années disponibles ── */
useEffect(() => {
@@ -253,11 +259,21 @@ export default function TaxReport() {
if (!loading && data && availableYears.length === 0) return (
<>
<div className="topbar"><h2>Fiscalité</h2></div>
<EmptyState
icon="📋"
title="Aucune donnée fiscale disponible"
message="Vous n'avez pas encore de données fiscales à déclarer. Ajoutez des plateformes et des remboursements pour voir apparaître votre récapitulatif fiscal."
/>
{hasPlateformes ? (
<EmptyState
icon="📋"
title="Aucune donnée fiscale pour le moment"
message="Vos plateformes sont configurées, mais aucun remboursement n'a encore été enregistré. Ajoutez un remboursement pour voir apparaître votre récapitulatif fiscal."
cta="Ajouter un remboursement"
to="/remboursements?new=1"
/>
) : (
<EmptyState
icon="📋"
title="Aucune donnée fiscale disponible"
message="Vous n'avez pas encore de données fiscales à déclarer. Ajoutez des plateformes et des remboursements pour voir apparaître votre récapitulatif fiscal."
/>
)}
</>
);
return (
+120 -34
View File
@@ -12,39 +12,59 @@ const CATEGORY_META = {
invitation: { label: 'Invitation', color: '#6366f1' },
};
const ACTION_LABELS = {
login_success: 'Connexion réussie',
login_failed: 'Échec de connexion',
login_2fa_success: 'Connexion 2FA réussie',
user_registered: 'Auto-inscription',
user_created: 'Compte créé (admin)',
user_deleted: 'Compte supprimé',
email_verified_admin:'Email vérifié (admin)',
role_changed: 'Rôle modifié',
status_changed: 'Statut modifié',
'2fa_enabled': '2FA activé',
'2fa_disabled': '2FA désactivé',
invitation_sent: 'Invitation envoyée',
invitation_accepted: 'Invitation acceptée',
// Statut succès / avertissement / échec par action
// IMPORTANT : garder synchronisé avec STATUS_ACTIONS dans backend/src/routes/auditLogs.js
const ACTION_STATUS = {
login_success: 'success',
login_2fa_success: 'success',
user_registered: 'success',
user_created: 'success',
email_verified_admin: 'success',
invitation_accepted: 'success',
invitation_sent: 'success',
'2fa_enabled': 'success',
role_changed: 'warning',
status_changed: 'warning',
'2fa_disabled': 'warning',
user_deleted: 'warning',
account_self_deleted: 'warning',
login_failed: 'failure',
};
// Helpers
const STATUS_META = {
success: { label: 'Succès', color: '#16a34a' },
warning: { label: 'Avertissement', color: '#f59e0b' },
failure: { label: 'Échec', color: '#dc2626' },
};
function fmtDateTime(str) {
if (!str) return '—';
const d = new Date(str.replace(' ', 'T') + (str.includes('+') ? '' : 'Z'));
return d.toLocaleString('fr-FR', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
});
function StatusIcon({ status, color }) {
const meta = STATUS_META[status];
if (!meta) return null;
const common = { width: 14, height: 14, viewBox: '0 0 24 24', fill: 'none', stroke: color || meta.color, strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' };
return (
<span style={{ display: 'inline-flex', marginRight: 6, verticalAlign: 'middle' }} title={meta.label}>
{status === 'success' && (
<svg {...common}><circle cx="12" cy="12" r="10"/><polyline points="16 9 11 14 8 11"/></svg>
)}
{status === 'warning' && (
<svg {...common}><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
)}
{status === 'failure' && (
<svg {...common}><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
)}
</span>
);
}
function CategoryBadge({ cat, active, onClick }) {
const meta = CATEGORY_META[cat] || { label: cat, color: '#6b7280' };
function StatusBadge({ status, count, active, onClick }) {
const meta = STATUS_META[status];
return (
<button
onClick={() => onClick(cat)}
onClick={() => onClick(status)}
style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '3px 10px',
borderRadius: 20,
border: `1px solid ${meta.color}`,
@@ -57,11 +77,56 @@ function CategoryBadge({ cat, active, onClick }) {
whiteSpace: 'nowrap',
}}
>
<StatusIcon status={status} color={active ? '#fff' : meta.color} />
{meta.label}
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
minWidth: 18, height: 18, borderRadius: 9, padding: '0 5px',
fontSize: 11, fontWeight: 700,
background: active ? 'rgba(255,255,255,.25)' : `${meta.color}22`,
color: active ? '#fff' : meta.color,
}}>
{count}
</span>
</button>
);
}
const ACTION_LABELS = {
login_success: 'Connexion réussie',
login_failed: 'Échec de connexion',
login_2fa_success: 'Connexion 2FA réussie',
user_registered: 'Auto-inscription',
user_created: 'Compte créé (admin)',
user_deleted: 'Compte supprimé',
account_self_deleted:'Compte supprimé (par lutilisateur)',
email_verified_admin:'Email vérifié (admin)',
role_changed: 'Rôle modifié',
status_changed: 'Statut modifié',
'2fa_enabled': '2FA activé',
'2fa_disabled': '2FA désactivé',
invitation_sent: 'Invitation envoyée',
invitation_accepted: 'Invitation acceptée',
};
// Helpers
function fmtPerson(name, email) {
if (name && email) return `${name} (${email})`;
if (email) return email;
if (name) return name;
return '—';
}
function fmtDateTime(str) {
if (!str) return '—';
const d = new Date(str.replace(' ', 'T') + (str.includes('+') ? '' : 'Z'));
return d.toLocaleString('fr-FR', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
});
}
function DetailTooltip({ details }) {
if (!details) return null;
const entries = typeof details === 'object'
@@ -119,9 +184,11 @@ export default function AuditLogsSection() {
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [categories, setCats] = useState([]);
const [statusCounts, setStatusCounts] = useState({ success: 0, warning: 0, failure: 0 });
// Filtres
const [filterCat, setFilterCat] = useState('');
const [filterStatus, setFilterStatus] = useState('');
const [filterSearch, setFilterSearch] = useState('');
const [filterFrom, setFilterFrom] = useState('');
const [filterTo, setFilterTo] = useState('');
@@ -133,6 +200,7 @@ export default function AuditLogsSection() {
try {
const params = { page: p, limit: LIMIT };
if (filterCat) params.category = filterCat;
if (filterStatus) params.status = filterStatus;
if (filterSearch) params.search = filterSearch;
if (filterFrom) params.dateFrom = filterFrom;
if (filterTo) params.dateTo = filterTo;
@@ -143,6 +211,7 @@ export default function AuditLogsSection() {
]);
setRows(data.rows);
setTotal(data.total);
setStatusCounts(data.statusCounts || { success: 0, warning: 0, failure: 0 });
setPage(p);
if (!categories.length) setCats(cats);
} catch (e) {
@@ -150,9 +219,9 @@ export default function AuditLogsSection() {
} finally {
setLoading(false);
}
}, [filterCat, filterSearch, filterFrom, filterTo]); // eslint-disable-line
}, [filterCat, filterStatus, filterSearch, filterFrom, filterTo]); // eslint-disable-line
useEffect(() => { load(1); }, [filterCat, filterFrom, filterTo]); // eslint-disable-line
useEffect(() => { load(1); }, [filterCat, filterStatus, filterFrom, filterTo]); // eslint-disable-line
// Recherche texte : debounce 350ms
useEffect(() => {
@@ -160,9 +229,10 @@ export default function AuditLogsSection() {
return () => clearTimeout(t);
}, [filterSearch]); // eslint-disable-line
const toggleCat = (cat) => setFilterCat(prev => prev === cat ? '' : cat);
const toggleStatus = (st) => setFilterStatus(prev => prev === st ? '' : st);
const allCats = Object.keys(CATEGORY_META);
const allStatuses = Object.keys(STATUS_META);
return (
<div>
@@ -173,10 +243,25 @@ export default function AuditLogsSection() {
</span>
</div>
{/* Filtres catégories */}
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 12 }}>
{allCats.map(cat => (
<CategoryBadge key={cat} cat={cat} active={filterCat === cat} onClick={toggleCat} />
{/* Filtres : catégorie (liste déroulante) + statut (chips avec compteurs) */}
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', marginBottom: 16 }}>
<select
value={filterCat}
onChange={e => setFilterCat(e.target.value)}
style={{
width: 'auto', minWidth: 180, flexShrink: 0,
height: 32, padding: '0 8px', borderRadius: 8, border: '1px solid var(--border)',
background: 'var(--surface)', color: 'var(--text)', fontSize: 13,
}}
>
<option value="">Toutes les catégories</option>
{allCats.map(cat => (
<option key={cat} value={cat}>{CATEGORY_META[cat]?.label || cat}</option>
))}
</select>
{allStatuses.map(st => (
<StatusBadge key={st} status={st} count={statusCounts[st] ?? 0} active={filterStatus === st} onClick={toggleStatus} />
))}
</div>
@@ -249,9 +334,9 @@ export default function AuditLogsSection() {
{rows.map((row, i) => {
const meta = CATEGORY_META[row.category] || { label: row.category, color: '#6b7280' };
const label = ACTION_LABELS[row.action] || row.action;
const actor = row.actor_name || row.actor_email || '—';
const actor = fmtPerson(row.actor_name, row.actor_email);
const target = row.target_email && row.target_id !== row.actor_id
? (row.target_name || row.target_email)
? fmtPerson(row.target_name, row.target_email)
: '—';
return (
@@ -275,6 +360,7 @@ export default function AuditLogsSection() {
</span>
</td>
<td style={{ padding: '7px 10px' }}>
<StatusIcon status={ACTION_STATUS[row.action]} />
<span style={{ color: 'var(--text)' }}>{label}</span>
<DetailTooltip details={row.details} />
</td>
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { api } from '../../api.js';
import PasswordInput from '../../components/PasswordInput.jsx';
export default function CreateUserSection({ onCreated }) {
const [form, setForm] = useState({ email: '', password: '', displayName: '', role: 'user' });
@@ -48,7 +49,7 @@ export default function CreateUserSection({ onCreated }) {
</div>
<div>
<label>Mot de passe *</label>
<input type="password" required minLength={8} value={form.password} onChange={e => set('password', e.target.value)} placeholder="8 caractères minimum" />
<PasswordInput required minLength={8} value={form.password} onChange={e => set('password', e.target.value)} placeholder="8 caractères minimum" />
</div>
<div>
<label>Rôle</label>
+29 -7
View File
@@ -5,6 +5,7 @@
import { useState, useEffect } from 'react';
import { api } from '../../api.js';
import { withDevOverrides } from '../../utils/devOverrides.js';
function SettingRow({ label, description, children }) {
return (
@@ -34,6 +35,7 @@ function SectionHeader({ title, description }) {
const DEFAULT = {
appName: 'Crowdlending Tracker',
appUrl: '',
mcpUrl: '',
allowRegistration: true,
minPasswordLength: 8,
};
@@ -46,12 +48,19 @@ export default function GeneralSection() {
useEffect(() => {
api.get('/admin/general')
.then(d => setForm({
appName: d.appName || 'Crowdlending Tracker',
appUrl: d.appUrl || '',
allowRegistration: d.allowRegistration !== false,
minPasswordLength: d.minPasswordLength || 8,
}))
.then(d => {
// En dev local, la base est régulièrement une copie de la prod :
// appUrl/mcpUrl y pointeraient encore vers l'environnement de prod
// tant qu'on ne les corrige pas voir utils/devOverrides.js.
const dd = withDevOverrides(d);
setForm({
appName: dd.appName || 'Crowdlending Tracker',
appUrl: dd.appUrl || '',
mcpUrl: dd.mcpUrl || '',
allowRegistration: dd.allowRegistration !== false,
minPasswordLength: dd.minPasswordLength || 8,
});
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
@@ -64,6 +73,7 @@ export default function GeneralSection() {
await api.patch('/admin/general', {
appName: form.appName.trim(),
appUrl: form.appUrl.trim(),
mcpUrl: form.mcpUrl.trim(),
allowRegistration: form.allowRegistration,
minPasswordLength: form.minPasswordLength,
});
@@ -103,7 +113,7 @@ export default function GeneralSection() {
/>
</SettingRow>
<SettingRow label="URL de la plateforme" description="Utilisée pour les boutons de redirection dans les emails. Inclure le protocole (https://).">
<SettingRow label="URL de la plateforme" description={`Utilisée pour les boutons de redirection dans les emails. Inclure le protocole (https://).${import.meta.env.DEV ? ' Valeur forcée en développement local (ignore celle de la base, potentiellement une copie de la prod).' : ''}`}>
<input
className="form-input"
type="url"
@@ -114,6 +124,18 @@ export default function GeneralSection() {
style={{ width: '100%' }}
/>
</SettingRow>
<SettingRow label="URL du serveur MCP" description={`Point d'entrée public du serveur MCP (endpoint /mcp inclus). Inclure le protocole (https://).${import.meta.env.DEV ? ' Valeur forcée en développement local (ignore celle de la base, potentiellement une copie de la prod).' : ''}`}>
<input
className="form-input"
type="url"
maxLength={500}
value={form.mcpUrl}
placeholder="https://mcp.mon-app.example.com/mcp"
onChange={e => set('mcpUrl', e.target.value)}
style={{ width: '100%' }}
/>
</SettingRow>
</div>
{/* Accès */}
+2 -1
View File
@@ -5,6 +5,7 @@ import ConfirmModal from '../../components/ConfirmModal.jsx';
import Modal from '../../components/Modal.jsx';
import ResultBanner from '../../components/ResultBanner.jsx';
import PasswordStrength from '../../components/PasswordStrength.jsx';
import PasswordInput from '../../components/PasswordInput.jsx';
import { fmt, Badge, UserStatusBadge } from './adminHelpers.jsx';
// Helpers
@@ -363,7 +364,7 @@ function CreateUserModal({ open, onClose, onCreated }) {
</div>
<div>
<label>Mot de passe *</label>
<input type="password" required minLength={minPasswordLength} autoComplete="new-password" value={form.password} onChange={e => set('password', e.target.value)} placeholder={`${minPasswordLength} caractères minimum`} />
<PasswordInput required minLength={minPasswordLength} autoComplete="new-password" value={form.password} onChange={e => set('password', e.target.value)} placeholder={`${minPasswordLength} caractères minimum`} />
<PasswordStrength password={form.password} minLength={minPasswordLength} />
</div>
<div>
@@ -132,6 +132,8 @@ export default function ComptesSection() {
});
};
const multiDetenteur = investisseurs.length > 1;
return (
<>
<div className="card">
@@ -154,7 +156,7 @@ export default function ComptesSection() {
<tr>
<th>Nom</th>
<th style={{ width: '14%' }}>Type</th>
<th style={{ width: '22%' }}>Détenteur</th>
{multiDetenteur && <th style={{ width: '22%' }}>Détenteur</th>}
<th style={{ width: '16%' }}>Banque</th>
<th style={{ width: '10%' }}>Exonération</th>
<th style={{ width: 80 }} />
@@ -162,7 +164,7 @@ export default function ComptesSection() {
</thead>
<tbody>
{comptes.length === 0 && (
<tr><td colSpan={6} className="text-muted" style={{ textAlign: 'center', fontStyle: 'italic' }}>
<tr><td colSpan={multiDetenteur ? 6 : 5} className="text-muted" style={{ textAlign: 'center', fontStyle: 'italic' }}>
Aucun compte défini.
</td></tr>
)}
@@ -172,7 +174,7 @@ export default function ComptesSection() {
<tr key={c.id}>
<td style={{ fontWeight: 500 }}>{c.nom}</td>
<td><span className="badge">{TYPE_COMPTE_LABELS[c.type] ?? c.type}</span></td>
<td>{inv ? memberLabel(inv) : <span className="text-muted"></span>}</td>
{multiDetenteur && <td>{inv ? memberLabel(inv) : <span className="text-muted"></span>}</td>}
<td>{c.banque || <span className="text-muted"></span>}</td>
<td>
{c.exoneration_fiscale && c.exoneration_fiscale !== 'aucune' ? (
@@ -1,10 +1,29 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { api } from '../../api.js';
function IconBroom() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M3 21l9-9"/><path d="M12.22 6.22L17 1.5l5.5 5.5-4.72 4.78"/><path d="M5 17c.5-2 2-3.5 4-4.5l3.5 3.5c-1 2-2.5 3.5-4.5 4"/></svg>;
}
const PURGE_SCOPES = [
{ value: 'all', label: 'Toutes les données (dépôts/retraits + investissements + remboursements)' },
{ value: 'depots_retraits', label: 'Dépôts / Retraits uniquement' },
{ value: 'investissements', label: "Investissements (et leurs remboursements liés)" },
{ value: 'remboursements', label: 'Remboursements uniquement (les investissements sont conservés)' },
];
function purgeSummary(counts, scope) {
const parts = [];
if (counts.depots_retraits > 0) parts.push(`${counts.depots_retraits} dépôt(s)/retrait(s)`);
if (counts.investissements > 0) parts.push(`${counts.investissements} investissement(s)`);
if (counts.remboursements > 0) parts.push(`${counts.remboursements} remboursement(s)`);
if (scope === 'investissements' && counts.simul_remboursements > 0) {
parts.push(`${counts.simul_remboursements} échéance(s) simulée(s)`);
}
if (parts.length === 0) return 'Aucune donnée à supprimer trouvée pour cette plateforme.';
return `Supprimé : ${parts.join(', ')}.`;
}
export default function DataCleanupSection() {
const [showModal, setShowModal] = useState(false);
const [loading, setLoading] = useState(false);
@@ -12,11 +31,62 @@ export default function DataCleanupSection() {
const [loadingReprocess, setLoadingReprocess] = useState(false);
const [showDiffereModal, setShowDiffereModal] = useState(false);
const [loadingDiffere, setLoadingDiffere] = useState(false);
const [seuilMoisDiffere, setSeuilMoisDiffere] = useState(24);
const [showEcheancierModal, setShowEcheancierModal] = useState(false);
const [loadingEcheancier, setLoadingEcheancier] = useState(false);
const [showBackfillModal, setShowBackfillModal] = useState(false);
const [loadingBackfill, setLoadingBackfill] = useState(false);
const [successMsg, setSuccessMsg] = useState(null);
const [errorMsg, setErrorMsg] = useState(null);
const [plats, setPlats] = useState([]);
const [purgePlatId, setPurgePlatId] = useState('');
const [purgeScope, setPurgeScope] = useState('all');
const [showPurgeModal, setShowPurgeModal] = useState(false);
const [purgeConfirmText, setPurgeConfirmText] = useState('');
const [purgePin, setPurgePin] = useState('');
const [loadingPurge, setLoadingPurge] = useState(false);
useEffect(() => {
api.get('/plateformes').then(setPlats).catch(() => {});
}, []);
const purgePlat = plats.find(p => String(p.id) === String(purgePlatId)) || null;
// Plusieurs membres de la famille/entreprise peuvent avoir une plateforme du même nom
// (ex. deux comptes "Enky" distincts) on ajoute le détenteur au libellé pour lever
// l'ambiguïté, uniquement quand plus d'un détenteur est présent dans la liste.
const multiDetenteurPlats = new Set(plats.map(p => p.investisseur_id)).size > 1;
const openPurgeModal = () => {
if (!purgePlat) return;
setPurgeConfirmText('');
setPurgePin(String(Math.floor(100000 + Math.random() * 900000)));
setShowPurgeModal(true);
};
const handlePurge = async () => {
if (!purgePlat || purgeConfirmText.trim() !== purgePin) return;
setLoadingPurge(true);
setErrorMsg(null);
setSuccessMsg(null);
try {
const r = await api.post(`/plateformes/${purgePlat.id}/purge-donnees`, {
scope: purgeScope,
// Le PIN est une confirmation côté interface uniquement ; l'API continue d'exiger
// le nom exact de la plateforme, déjà connu ici puisque sélectionné dans le formulaire.
confirmNom: purgePlat.nom,
});
setSuccessMsg(`"${r.plateforme}" — ${purgeSummary(r.counts, purgeScope)}`);
setShowPurgeModal(false);
setPurgeConfirmText('');
} catch (err) {
setErrorMsg(err.message || 'Une erreur est survenue.');
setShowPurgeModal(false);
} finally {
setLoadingPurge(false);
}
};
const handleReprocess = async () => {
setLoadingReprocess(true);
setErrorMsg(null);
@@ -54,13 +124,18 @@ export default function DataCleanupSection() {
setErrorMsg(null);
setSuccessMsg(null);
try {
const { updated, detail } = await api.post('/investissements/fix-differe-dates', {});
const { updated, detail, statutsMisAJour } = await api.post('/investissements/fix-differe-dates', {
seuilMois: seuilMoisDiffere,
});
if (updated === 0) {
setSuccessMsg('Aucune date incohérente détectée sur les prêts différés.');
} else {
const suffixeStatuts = statutsMisAJour > 0
? ` (statut recalculé pour ${statutsMisAJour} prêt${statutsMisAJour > 1 ? 's' : ''} suite à la correction)`
: '';
setSuccessMsg(
`${updated} prêt${updated > 1 ? 's' : ''} différé${updated > 1 ? 's' : ''} corrigé${updated > 1 ? 's' : ''} : ` +
detail.map(d => d.nom_projet).join(', ') + '.'
detail.map(d => d.nom_projet).join(', ') + '.' + suffixeStatuts
);
}
setShowDiffereModal(false);
@@ -72,6 +147,29 @@ export default function DataCleanupSection() {
}
};
const handleCheckEcheancierDiffere = async () => {
setLoadingEcheancier(true);
setErrorMsg(null);
setSuccessMsg(null);
try {
const { updated, detail } = await api.post('/investissements/check-echeancier-differe', {});
if (updated === 0) {
setSuccessMsg("Échéancier cohérent pour tous les prêts différés — rien à corriger.");
} else {
setSuccessMsg(
`Échéancier régénéré pour ${updated} prêt${updated > 1 ? 's' : ''} différé${updated > 1 ? 's' : ''} : ` +
detail.map(d => d.nom_projet).join(', ') + '.'
);
}
setShowEcheancierModal(false);
} catch (err) {
setErrorMsg(err.message || 'Une erreur est survenue.');
setShowEcheancierModal(false);
} finally {
setLoadingEcheancier(false);
}
};
const handleBackfillComptes = async () => {
setLoadingBackfill(true);
setErrorMsg(null);
@@ -135,8 +233,8 @@ export default function DataCleanupSection() {
</div>
<div className="text-muted" style={{ fontSize: 12 }}>
Recalcule la date de 1ère échéance et la date cible à partir de la date de souscription
et de la durée prévue. Seuls les prêts dont les dates s'écartent de plus de 2 ans
de la valeur calculée sont corrigés.
et de la durée prévue. Seuls les prêts dont les dates s'écartent de la valeur calculée
de plus qu'un seuil choisi (3 à 24 mois) sont corrigés.
</div>
</div>
<button
@@ -148,6 +246,30 @@ export default function DataCleanupSection() {
</button>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '14px 16px', borderRadius: 8,
border: '1px solid var(--border)', background: 'var(--bg-secondary, var(--bg))',
marginBottom: 10 }}>
<div>
<div style={{ fontWeight: 500, fontSize: 'var(--fs-sm)', marginBottom: 2 }}>
Vérifier la cohérence de l'échéancier des prêts différés
</div>
<div className="text-muted" style={{ fontSize: 12 }}>
Contrôle que l'échéancier de projection de chaque prêt différé comporte bien une unique
échéance, à la date de 1ère échéance / date cible enregistrée sur le prêt. Régénère
automatiquement l'échéancier en cas d'écart (ex. date corrigée sans mise à jour de la
simulation).
</div>
</div>
<button
style={{ marginLeft: 16, whiteSpace: 'nowrap', flexShrink: 0 }}
onClick={() => setShowEcheancierModal(true)}
disabled={loadingEcheancier}
>
Vérifier
</button>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '14px 16px', borderRadius: 8,
border: '1px solid var(--border)', background: 'var(--bg-secondary, var(--bg))',
@@ -171,6 +293,45 @@ export default function DataCleanupSection() {
</button>
</div>
<div style={{ padding: '14px 16px', borderRadius: 8,
border: '1px solid var(--border)', background: 'var(--bg-secondary, var(--bg))',
marginBottom: 10 }}>
<div style={{ fontWeight: 500, fontSize: 'var(--fs-sm)', marginBottom: 2 }}>
Supprimer les données d'une plateforme
</div>
<div className="text-muted" style={{ fontSize: 12, marginBottom: 12 }}>
Efface les données rattachées à une plateforme (utile pour repartir d'une base propre avant
une réimportation). La fiche plateforme elle-même n'est jamais supprimée par cette action.
</div>
<div className="row" style={{ gap: 10, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<div style={{ flex: '1 1 220px', minWidth: 200 }}>
<label style={{ fontSize: 12 }}>Plateforme</label>
<select value={purgePlatId} onChange={e => setPurgePlatId(e.target.value)}>
<option value="">Sélectionnez une plateforme</option>
{plats.map(p => (
<option key={p.id} value={p.id}>
{p.nom}{multiDetenteurPlats && p.investisseur_nom ? `${p.investisseur_nom}` : ''}
</option>
))}
</select>
</div>
<div style={{ flex: '1 1 280px', minWidth: 240 }}>
<label style={{ fontSize: 12 }}>Données à supprimer</label>
<select value={purgeScope} onChange={e => setPurgeScope(e.target.value)}>
{PURGE_SCOPES.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
</div>
<button
className="danger"
style={{ flexShrink: 0 }}
onClick={openPurgeModal}
disabled={!purgePlatId || loadingPurge}
>
Supprimer
</button>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '14px 16px', borderRadius: 8,
border: '1px solid var(--border)', background: 'var(--bg-secondary, var(--bg))' }}>
@@ -207,11 +368,28 @@ export default function DataCleanupSection() {
border: '1px solid var(--border)' }}>
date souscription + durée (mois)
</p>
<p style={{ margin: '0 0 20px', lineHeight: 1.6 }} className="text-muted">
<p style={{ margin: '0 0 12px', lineHeight: 1.6 }} className="text-muted">
La correction ne s'applique que si l'écart entre la date existante et la date calculée
dépasse <strong>2 ans</strong>. Les simulations de remboursement associées ne sont pas
recalculées automatiquement.
dépasse le seuil choisi ci-dessous. L'échéancier de projection (simulation de
remboursement) est régénéré automatiquement avec la date corrigée.
</p>
<div style={{ margin: '0 0 20px' }}>
<label style={{ display: 'block', fontSize: 'var(--fs-sm)', fontWeight: 500, marginBottom: 6 }}>
Seuil d'écart
</label>
<select
value={seuilMoisDiffere}
onChange={e => setSeuilMoisDiffere(Number(e.target.value))}
disabled={loadingDiffere}
style={{ width: '100%' }}
>
<option value={3}>3 mois</option>
<option value={6}>6 mois</option>
<option value={12}>12 mois</option>
<option value={18}>18 mois</option>
<option value={24}>24 mois (défaut)</option>
</select>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={() => setShowDiffereModal(false)} disabled={loadingDiffere}>Annuler</button>
<button className="primary" onClick={() => handleFixDiffereDates()} disabled={loadingDiffere}>
@@ -222,6 +400,31 @@ export default function DataCleanupSection() {
</div>
)}
{showEcheancierModal && (
<div className="modal-overlay" onClick={() => setShowEcheancierModal(false)}>
<div className="modal" style={{ maxWidth: 480 }} onClick={e => e.stopPropagation()}>
<div className="modal-header" style={{ borderBottom: '1px solid var(--border)', paddingBottom: 12, marginBottom: 16 }}>
<h3 style={{ margin: 0 }}>Vérifier la cohérence de l'échéancier des prêts différés</h3>
</div>
<p style={{ margin: '0 0 12px', lineHeight: 1.6 }}>
Pour chaque prêt de type <strong>différé</strong>, cette opération vérifie que l'échéancier
de projection (simulation de remboursement) contient bien une unique échéance, à la date
enregistrée sur le prêt (1ère échéance / date cible).
</p>
<p style={{ margin: '0 0 20px', lineHeight: 1.6 }} className="text-muted">
En cas d'écart (échéance manquante, en double, ou à une date différente de celle du prêt),
l'échéancier est régénéré automatiquement à partir des données actuelles du prêt.
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={() => setShowEcheancierModal(false)} disabled={loadingEcheancier}>Annuler</button>
<button className="primary" onClick={handleCheckEcheancierDiffere} disabled={loadingEcheancier}>
{loadingEcheancier ? 'Vérification en cours…' : 'Lancer la vérification'}
</button>
</div>
</div>
</div>
)}
{showBackfillModal && (
<div className="modal-overlay" onClick={() => setShowBackfillModal(false)}>
<div className="modal" style={{ maxWidth: 480 }} onClick={e => e.stopPropagation()}>
@@ -276,7 +479,65 @@ export default function DataCleanupSection() {
</div>
)}
{showPurgeModal && purgePlat && (
<div className="modal-overlay" onClick={() => setShowPurgeModal(false)}>
<div className="modal" style={{ maxWidth: 480 }} onClick={e => e.stopPropagation()}>
<div className="modal-header" style={{ borderBottom: '1px solid var(--border)', paddingBottom: 12, marginBottom: 16 }}>
<h3 style={{ margin: 0, color: 'var(--danger, #ef4444)' }}> Suppression de données action irréversible</h3>
</div>
<p style={{ margin: '0 0 12px', lineHeight: 1.6 }}>
Vous vous apprêtez à supprimer, pour la plateforme <strong>{purgePlat.nom}</strong>
{multiDetenteurPlats && purgePlat.investisseur_nom ? <> (<strong>{purgePlat.investisseur_nom}</strong>)</> : null} :
</p>
<p style={{ margin: '0 0 12px', lineHeight: 1.6, fontFamily: 'monospace', fontSize: 13,
background: 'var(--surface-2, var(--bg))', padding: '8px 12px', borderRadius: 6,
border: '1px solid var(--border)' }}>
{PURGE_SCOPES.find(s => s.value === purgeScope)?.label}
</p>
<p style={{ margin: '0 0 16px', lineHeight: 1.6 }} className="text-muted">
La fiche plateforme "{purgePlat.nom}" est conservée. Cette opération ne peut pas être annulée.
</p>
<div style={{ margin: '0 0 20px' }}>
<label style={{ display: 'block', fontSize: 'var(--fs-sm)', fontWeight: 500, marginBottom: 8 }}>
Pour confirmer, retapez le code affiché ci-dessous :
</label>
<div style={{
textAlign: 'center', margin: '0 0 14px', padding: '14px 10px',
background: 'var(--surface-2, var(--bg))', border: '1px dashed var(--danger, #ef4444)',
borderRadius: 8,
}}>
<span style={{
fontFamily: 'monospace', fontSize: 30, fontWeight: 700,
letterSpacing: 8, color: 'var(--danger, #ef4444)',
}}>
{purgePin}
</span>
</div>
<input
type="text"
inputMode="numeric"
maxLength={6}
value={purgeConfirmText}
onChange={e => setPurgeConfirmText(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="000000"
disabled={loadingPurge}
style={{ width: '100%', textAlign: 'center', fontFamily: 'monospace', fontSize: 18, letterSpacing: 4 }}
/>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={() => setShowPurgeModal(false)} disabled={loadingPurge}>Annuler</button>
<button
className="danger"
onClick={handlePurge}
disabled={loadingPurge || purgeConfirmText.trim() !== purgePin}
>
{loadingPurge ? 'Suppression en cours…' : 'Confirmer la suppression'}
</button>
</div>
</div>
</div>
)}
{showModal && (
<div className="modal-overlay" onClick={() => setShowModal(false)}>
<div className="modal" style={{ maxWidth: 440 }} onClick={e => e.stopPropagation()}>
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { api } from '../../api.js';
import InvSelect from '../../components/InvSelect.jsx';
import Modal from '../../components/Modal.jsx';
@@ -17,6 +17,12 @@ function dlBlob(content, filename, type) {
URL.revokeObjectURL(url);
}
/** Date du jour au format YYYY-MM-DD, en heure locale (ne pas utiliser toISOString ici, décale d'un jour selon le fuseau). */
function localToday() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
const countryLabel = code => COUNTRIES.find(c => c.code === code)?.name ?? code ?? '—';
const FISCALITE_LABELS = {
flat_tax: 'Flat Tax',
@@ -243,7 +249,20 @@ function PlatDetailPanel({ plat, onEdit }) {
/>
</div>
)}
<div className="dr-detail-title">Détail de la plateforme</div>
<div className="dr-detail-title" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span>Détail de la plateforme</span>
{plat.referentiel_id && (
<button
type="button"
className="chip-cat"
onClick={() => navigate(`/referentiel/${plat.referentiel_id}`)}
title="Voir le profil de la plateforme"
style={{ cursor: 'pointer' }}
>
Profil
</button>
)}
</div>
<div className="dr-detail-fields">
{fields.map(f => (
<div className="dr-detail-field" key={f.label}>
@@ -253,15 +272,6 @@ function PlatDetailPanel({ plat, onEdit }) {
))}
</div>
<div className="dr-detail-footer">
{plat.referentiel_id && (
<button
className="ghost"
onClick={() => navigate(`/referentiel/${plat.referentiel_id}`)}
style={{ marginBottom: 8, width: '100%', fontSize: 13 }}
>
Voir le profil de la plateforme
</button>
)}
<button className="dr-detail-edit-btn" onClick={() => onEdit(plat)}>
Modifier la plateforme
</button>
@@ -279,6 +289,13 @@ function PlatDetailPanel({ plat, onEdit }) {
function PlatPickerModal({ open, onClose, referentiel, onSelect, onManual, err }) {
const [search, setSearch] = useState('');
const [domFilter, setDomFilter] = useState('all'); // 'all' | 'fr' | 'etr'
const [confirming, setConfirming] = useState(null); // ref en cours de confirmation
const [dateOuverture, setDateOuverture] = useState('');
useEffect(() => {
if (open) { setConfirming(null); setDateOuverture(''); }
}, [open]);
if (!open) return null;
const q = search.trim().toLowerCase();
@@ -294,6 +311,53 @@ function PlatPickerModal({ open, onClose, referentiel, onSelect, onManual, err }
return f ? (LOGO_BASE + f) : null;
};
/* ── Étape de confirmation : date d'ouverture du compte ── */
if (confirming) {
const img = imgFor(confirming);
return (
<Modal
open={open}
title="Ajouter une plateforme"
onClose={onClose}
width={480}
footer={
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
<button type="button" className="ghost" onClick={() => setConfirming(null)}> Retour</button>
<button type="button" className="primary" onClick={() => onSelect(confirming, dateOuverture)}>
Ajouter
</button>
</div>
}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{err && <div className="error">{err}</div>}
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{img
? <img src={img} alt={confirming.nom} style={{ width: 44, height: 44, objectFit: 'contain', borderRadius: 8, flexShrink: 0 }} />
: <div style={{ width: 44, height: 44, borderRadius: 8, flexShrink: 0, background: 'var(--surface-2)', border: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 18, color: 'var(--text-muted)' }}>🏦</div>
}
<div style={{ fontWeight: 600, fontSize: 'var(--fs-md)' }}>{confirming.nom}</div>
</div>
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: 'var(--fs-sm)', lineHeight: 1.5 }}>
Pouvez-vous SVP préciser la date d'ouverture de votre compte sur cette plateforme ?
</p>
<div>
<label style={{ display: 'block', fontWeight: 600, marginBottom: 4, fontSize: 'var(--fs-sm)' }}>
Date d'ouverture du compte
</label>
<input
type="date"
autoFocus
value={dateOuverture}
onChange={e => setDateOuverture(e.target.value)}
style={{ width: '100%', boxSizing: 'border-box' }}
/>
</div>
</div>
</Modal>
);
}
return (
<Modal
open={open}
@@ -368,7 +432,7 @@ function PlatPickerModal({ open, onClose, referentiel, onSelect, onManual, err }
<button
key={r.id}
type="button"
onClick={() => onSelect(r)}
onClick={() => { setConfirming(r); setDateOuverture(localToday()); }}
style={{
display: 'flex', flexDirection: 'column', alignItems: 'center',
gap: 8, padding: '16px 10px 12px',
@@ -572,9 +636,9 @@ function PlatForm({ state, setter, logoFile, setLogoFile, logoPreview, setLogoPr
const inherited = (state.inherited_cat_ids || []).includes(c.id);
const checked = (state.categories_inv_ids || []).includes(c.id);
return (
<label key={c.id} style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: inherited ? 'default' : 'pointer',
opacity: inherited ? 0.6 : 1 }}>
<input type="checkbox" checked={checked} disabled={inherited} style={{ width: 'auto' }}
<label key={c.id} style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}
title={inherited ? 'Catégorie héritée du référentiel — décochez pour la retirer de cette plateforme' : undefined}>
<input type="checkbox" checked={checked} style={{ width: 'auto' }}
onChange={e => setter(s => ({
...s,
categories_inv_ids: e.target.checked
@@ -595,9 +659,9 @@ function PlatForm({ state, setter, logoFile, setLogoFile, logoPreview, setLogoPr
const inherited = (state.inherited_sect_ids || []).includes(s.id);
const checked = (state.secteurs_inv_ids || []).includes(s.id);
return (
<label key={s.id} style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: inherited ? 'default' : 'pointer',
opacity: inherited ? 0.6 : 1 }}>
<input type="checkbox" checked={checked} disabled={inherited} style={{ width: 'auto' }}
<label key={s.id} style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}
title={inherited ? 'Secteur hérité du référentiel — décochez pour le retirer de cette plateforme' : undefined}>
<input type="checkbox" checked={checked} style={{ width: 'auto' }}
onChange={e => setter(prev => ({
...prev,
secteurs_inv_ids: e.target.checked
@@ -629,6 +693,8 @@ function PlatForm({ state, setter, logoFile, setLogoFile, logoPreview, setLogoPr
}
export default function PlateformesSection() {
const [searchParams, setSearchParams] = useSearchParams();
// State
const [plats, setPlats] = useState([]);
const [investisseurs, setInvestisseurs] = useState([]);
@@ -693,6 +759,17 @@ export default function PlateformesSection() {
setSelectedPlat(prev => prev ? prev : plats[0]);
}, [plats]); // eslint-disable-line
// Ouverture automatique du picker d'ajout (venant de l'état vide "Aucune plateforme configurée")
useEffect(() => {
if (searchParams.get('openAdd') === '1') {
setErr(null);
setShowAddPicker(true);
const next = new URLSearchParams(searchParams);
next.delete('openAdd');
setSearchParams(next, { replace: true });
}
}, [searchParams]); // eslint-disable-line
const handleLogoFile = (file, setFile, setPreview) => {
if (!file) { setFile(null); setPreview(null); return; }
setFile(file);
@@ -762,16 +839,20 @@ export default function PlateformesSection() {
};
/* ── Ajouter depuis le référentiel ──────────────────────────── */
const addPlatFromRef = async (ref) => {
const addPlatFromRef = async (ref, dateOuverture) => {
try {
await api.post('/plateformes', {
nom: ref.nom,
url: ref.url || '',
domiciliation: ref.domiciliation || 'FR',
fiscalite: ref.fiscalite || 'flat_tax',
taux_fiscalite_locale: ref.taux_fiscalite_locale ?? null,
type_produit_fiscal: ref.type_produit_fiscal || '2TT',
methode_remboursement: 'portefeuille',
methode_remboursement: ref.methode_remboursement || 'portefeuille',
type_pret_defaut: ref.type_pret_defaut || null,
freq_interets_defaut: ref.freq_interets_defaut || null,
referentiel_id: ref.id,
date_ouverture: dateOuverture || null,
});
setShowAddPicker(false);
await load();
@@ -922,6 +1003,8 @@ export default function PlateformesSection() {
} catch (e) { setErr(e.message); }
};
const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1;
/* ── PFU CRUD ────────────────────────────────────────────────── */
@@ -980,14 +1063,14 @@ export default function PlateformesSection() {
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Domiciliation</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Catégories</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Secteurs</th>
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Détenteur</th>
{multiDetenteur && <th style={{ padding: '10px 8px', textAlign: 'left' }}>Détenteur</th>}
<th style={{ padding: '10px 16px', textAlign: 'center', width: 60 }}>Invest.</th>
<th style={{ padding: '10px 8px', width: 40 }}></th>
</tr>
</thead>
<tbody>
{plats.length === 0 && (
<tr><td colSpan={7} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>Aucune plateforme</td></tr>
<tr><td colSpan={multiDetenteur ? 8 : 7} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>Aucune plateforme</td></tr>
)}
{plats.map((p, i) => {
const imgSrc = p.icone_filename ? logoUrl(p.icone_filename) : p.logo_filename ? logoUrl(p.logo_filename) : null;
@@ -1034,9 +1117,11 @@ export default function PlateformesSection() {
{sects.length === 0 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}></span>}
</div>
</td>
<td style={{ padding: '10px 8px', fontSize: 12, color: 'var(--text-muted)' }}>
{inv ? memberLabel(inv) : ''}
</td>
{multiDetenteur && (
<td style={{ padding: '10px 8px', fontSize: 12, color: 'var(--text-muted)' }}>
{inv ? memberLabel(inv) : '—'}
</td>
)}
<td style={{ padding: '10px 16px', textAlign: 'center', fontWeight: 600,
color: (p.nb_investissements ?? 0) > 0 ? 'var(--text)' : 'var(--text-muted)' }}>
{p.nb_investissements ?? 0}
+33 -1
View File
@@ -724,11 +724,24 @@ tr:hover td { background: var(--surface-2); }
}
/* Modal */
.modal-backdrop {
.modal-backdrop, .modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,.55);
display: flex; align-items: center; justify-content: center;
z-index: 100; padding: 20px;
}
/* Boîte de contenu des modales "ad hoc" (celles qui n'utilisent pas le composant
partagé Modal.jsx) : mêmes fondations visuelles que .card, avec gestion du
débordement pour les contenus longs (ex. listes de doublons). */
.modal {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: var(--shadow);
padding: 20px;
width: 100%;
overflow: auto;
}
.modal-header { display: flex; justify-content: space-between; align-items: center; }
/* Login */
.login-shell { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: var(--bg); }
@@ -1338,6 +1351,17 @@ tr:hover td { background: var(--surface-2); }
padding: 24px 0 32px 28px;
min-width: 0;
}
/* Variante pour les pages "compte" à faible densité de contenu (MonCompte) :
évite que les cards s'étirent sur toute la largeur d'un grand écran.
Centrage via flex (plus fiable qu'un margin:auto sur un enfant de grid). */
.account-content-center {
display: flex;
justify-content: center;
}
.account-content-narrow {
width: 100%;
max-width: 760px;
}
/* AccountForm interne — profil + préférences côte-à-côte */
.profile-page {
@@ -1406,6 +1430,14 @@ tr:hover td { background: var(--surface-2); }
}
.cell-tooltip:hover::after { opacity: 1; }
/* Variante ouvrant vers le bas pour les éléments proches du haut de la fenêtre
(ex. icône d'info dans une carte KPI), le popup par défaut (vers le haut)
sortirait de l'écran. */
.cell-tooltip.tooltip-down::after {
top: calc(100% + 6px);
bottom: auto;
}
/* ── Tableau Remboursements enregistrés (InvestissementDetail) ────────── */
.remb-table { table-layout: fixed; width: 100%; }
.remb-table th, .remb-table td { text-align: center; }
+27
View File
@@ -0,0 +1,27 @@
/**
* devOverrides.js Neutralise les paramètres généraux venant de la base en
* développement local.
*
* Contexte : la base SQLite locale est régulièrement remplacée par une copie
* de la base de production (Admin Export complet, puis restauration en
* local) ce qui inclut les paramètres généraux (URL de la plateforme, URL
* du serveur MCP). Une fois rejouée en local, cette copie pointe encore vers
* l'environnement de prod tant qu'elle n'a pas été corrigée à la main.
*
* `import.meta.env.DEV` est une constante figée par Vite AU MOMENT DU BUILD :
* `true` uniquement quand le code tourne via `vite` (npm run dev), toujours
* `false` dans un build de production (`vite build`), quel que soit le
* contenu de la base utilisée à l'exécution. Aucun effet possible en
* production, donc.
*/
export const DEV_APP_URL = 'http://localhost:5173';
export const DEV_MCP_URL = 'http://localhost:4100/mcp';
/** Retourne `info` (forme de /api/app-info ou /api/admin/general) avec
* `appUrl`/`mcpUrl` forcés aux valeurs locales en développement. Inchangé
* tel quel en production. */
export function withDevOverrides(info) {
if (!import.meta.env.DEV) return info;
return { ...info, appUrl: DEV_APP_URL, mcpUrl: DEV_MCP_URL };
}
+27
View File
@@ -0,0 +1,27 @@
/**
* XIRR taux de rendement interne annualisé sur flux datés.
* cashflows : [{ amount: number, date: string 'YYYY-MM-DD' }]
* le premier flux doit être négatif (investissement initial).
* Retourne le taux annualisé (ex: 0.112 = 11.2%) ou null si non convergent.
*/
export function xirr(cashflows) {
if (!cashflows || cashflows.length < 2) return null;
const t0 = new Date(cashflows[0].date).getTime();
const years = cashflows.map(cf => (new Date(cf.date).getTime() - t0) / (365.25 * 864e5));
const amounts = cashflows.map(cf => cf.amount);
const npv = r => amounts.reduce((s, a, i) => s + a / Math.pow(1 + r, years[i]), 0);
const dnpv = r => amounts.reduce((s, a, i) => s - years[i] * a / Math.pow(1 + r, years[i] + 1), 0);
let rate = 0.1;
for (let i = 0; i < 200; i++) {
const f = npv(rate);
const df = dnpv(rate);
if (Math.abs(df) < 1e-12) break;
const next = rate - f / df;
if (Math.abs(next - rate) < 1e-8) return isFinite(next) ? next : null;
rate = next;
if (rate < -0.999) rate = -0.999; // garde-fou
}
return null;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
node_modules
npm-debug.log
*.log
README.md
+15
View File
@@ -0,0 +1,15 @@
FROM node:20-bookworm-slim
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json* ./
RUN npm install --omit=dev
COPY tools.js server.js ./
EXPOSE 4100
HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=10s \
CMD node -e "fetch('http://localhost:4100/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
CMD ["node", "server.js"]
+271
View File
@@ -0,0 +1,271 @@
# crowdlending-mcp-server
Serveur MCP (HTTP, Streamable HTTP transport) pour le portefeuille de
crowdlending. Il expose en lecture seule les données d'un investisseur
(investissements, remboursements, dépôts/retraits, dashboard) à un client
MCP — Claude Desktop, Claude Code, ou tout autre client compatible — en
s'appuyant sur l'API publique `/api/v1` du backend.
Il ne fait aucune écriture : toutes les modifications restent à faire dans
l'app web.
**Un seul serveur, un seul fichier (`server.js`)**, utilisé aussi bien en
développement local (`npm run dev`, comme le backend et le frontend) qu'en
production (service Docker `crowdlending-mcp`, derrière Traefik sur
`mcp.crowdlending.croguennec.net`). Pas de distinction de code entre les
deux — seule la configuration change (quelle API cibler, quels outils
activer).
## Prérequis
- Node.js ≥ 18 (fetch natif requis)
- Une clé API générée dans l'app : **Mon compte → Clés API → Nouvelle clé**
- Le backend accessible (en local `http://localhost:4000`, ou l'URL de votre
instance en production)
## Développement local
Comme pour `backend/` et `frontend/` :
```
cd mcp-server
npm install
npm run dev
```
`npm run dev` (comme `backend`) relance automatiquement le serveur à chaque
modification de fichier (`node --watch`). Par défaut, il écoute sur
`http://localhost:4100` et cible l'API locale (`http://localhost:4000/api/v1`).
Connectez ensuite Claude Desktop : Réglages → Développeur → Serveurs MCP
locaux → **Modifier la config**, puis ajoutez dans `mcpServers` :
```json
// macOS / Linux
{
"mcpServers": {
"crowdlending-dev": {
"command": "npx",
"args": [
"-y",
"mcp-remote",
"http://localhost:4100/mcp",
"--header",
"X-API-Key:${CROWDLENDING_API_KEY}"
],
"env": {
"CROWDLENDING_API_KEY": "clk_live_..."
}
}
}
}
```
```json
// Windows
{
"mcpServers": {
"crowdlending-dev": {
"command": "cmd",
"args": [
"/c", "npx",
"-y",
"mcp-remote",
"http://localhost:4100/mcp",
"--header",
"X-API-Key:${CROWDLENDING_API_KEY}"
],
"env": {
"CROWDLENDING_API_KEY": "clk_live_..."
}
}
}
}
```
**Important :** `claude_desktop_config.json` n'accepte que des entrées
`command`/`args` — il n'existe pas de champ `url`/`headers` natif dans ce
fichier (contrairement à d'autres clients MCP). Pour un serveur HTTP comme
celui-ci, on passe donc par
[`mcp-remote`](https://github.com/geelen/mcp-remote), un petit pont
stdio↔HTTP officiel : Claude Desktop lance `npx mcp-remote` comme d'habitude,
qui se charge de parler HTTP à notre serveur en coulisses, en-tête
`X-API-Key` inclus. Aucune installation manuelle requise, `npx` le télécharge
à la volée.
**Sous Windows, le wrapper `cmd /c` est obligatoire** (voir le second bloc
ci-dessus) : `npx` y est en réalité `npx.cmd` (un script), et la façon dont
Claude Desktop lance les process (`spawn` sans interpréteur de commandes) ne
sait pas l'exécuter directement — sans ce wrapper, le serveur reste affiché
comme « running » dans Claude Desktop mais ne répond jamais (blocage
silencieux, pas d'erreur explicite). C'est un problème Node.js/Windows
connu, pas spécifique à ce serveur — la config générée automatiquement dans
Mon compte → Serveur MCP l'applique déjà si elle détecte Windows.
Notez aussi l'absence d'espace autour du `:` dans `--header` : Claude Desktop
(Windows) a un bug connu qui tronque les arguments contenant un espace — on
passe donc la valeur réelle (avec l'espace éventuel) via une variable
d'environnement dans `env` plutôt que directement dans `args`.
Redémarrez Claude Desktop. Les outils `crowdlending_*` doivent apparaître
(voir la liste plus bas — `crowdlending_fetch_url` en plus si activé, voir
Configuration).
## Configuration
Variables d'environnement, toutes optionnelles sauf pour un déploiement
distant réel (en local, les valeurs par défaut conviennent) :
| Variable | Défaut | Description |
|---|---|---|
| `PORT` | `4100` | Port d'écoute HTTP |
| `CROWDLENDING_API_URL` | `http://localhost:4000/api/v1` | API v1 ciblée |
| `MCP_LABEL` | — | Étiquette d'environnement (`dev`, `prod`...) — voir plus bas |
| `MCP_ENABLE_FETCH_URL` | `false` | Active l'outil `crowdlending_fetch_url` (voir plus bas) |
| `MCP_ALLOWED_HOSTS` | `mcp.crowdlending.croguennec.net,localhost` | En-têtes `Host` acceptés (protection anti DNS-rebinding) |
Aucune clé API fixe n'est configurée côté serveur, contrairement à une
ancienne version qui utilisait le transport stdio : chaque session MCP lit
sa propre clé dans l'en-tête `X-API-Key` de la requête qui l'initialise
(transmis via `mcp-remote --header`, voir ci-dessus). Un même process peut
donc servir plusieurs utilisateurs/sessions en parallèle sans jamais mélanger
leurs données — voir le déploiement distant plus bas.
## Faire tourner dev et prod en même temps
Claude Desktop peut se connecter à plusieurs serveurs MCP simultanément : il
suffit de déclarer deux entrées dans `mcpServers`, une par URL. Chaque outil
est automatiquement rattaché à son serveur d'origine — pas de collision
possible, même si les noms d'outils sont identiques des deux côtés.
```json
{
"mcpServers": {
"crowdlending-dev": {
"command": "npx",
"args": ["-y", "mcp-remote", "http://localhost:4100/mcp", "--header", "X-API-Key:${DEV_API_KEY}"],
"env": { "DEV_API_KEY": "clk_live_..." }
},
"crowdlending-prod": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://mcp.crowdlending.croguennec.net/mcp", "--header", "X-API-Key:${PROD_API_KEY}"],
"env": { "PROD_API_KEY": "clk_live_..." }
}
}
}
```
Sous Windows, remplacez `"command": "npx"` par `"command": "cmd", "args": ["/c", "npx", ...]`
sur chacune des deux entrées (voir Développement local plus haut).
Utilisez deux clés API différentes (une par instance) : ça permet de
révoquer l'une sans affecter l'autre. Réglez `MCP_LABEL=dev` (côté serveur
local, dans votre `.env` ou variable d'environnement au lancement) pour que
le titre de chaque outil affiche `[DEV]` et lève toute ambiguïté — le
serveur distant est déjà configuré avec `MCP_LABEL` correspondant en
production.
## Déploiement en production (Docker)
Le service `crowdlending-mcp` du `docker-compose.yml` à la racine construit
et lance ce même `server.js`, exposé via Traefik sur
`mcp.crowdlending.croguennec.net` (certificat TLS automatique). Contrairement
au reste de l'app, ce service n'a **pas** le middleware `ipwhitelist-all` :
il est volontairement accessible depuis internet, pour des utilisateurs
distants qui ne peuvent pas faire tourner le serveur en local — la clé API
est donc la seule barrière d'accès. Traitez-la comme un mot de passe, et
révoquez-la immédiatement en cas de doute (Mon compte → Clés API).
Un enregistrement DNS pour ce sous-domaine, pointant vers la même IP que
`crowdlending.croguennec.net`, est nécessaire avant le premier déploiement.
`MCP_ENABLE_FETCH_URL` reste à `false` en production (voir docker-compose.yml)
: cet outil lit une URL arbitraire fournie par l'appelant, acceptable pour
un usage perso où vous seul détenez la clé, mais risqué (SSRF) face à des
utilisateurs distants non maîtrisés.
Autres protections : limite de 60 requêtes/minute par IP (au-delà, `429`),
et fermeture automatique des sessions inactives depuis plus de 30 minutes
(mémoire uniquement, aucune persistance).
## Tester sans Claude Desktop
Le [MCP Inspector](https://github.com/modelcontextprotocol/inspector) permet
de lister et appeler les outils depuis une interface web :
```
npm run inspect
```
Dans l'interface, choisissez le transport **Streamable HTTP**, entrez
l'URL (`http://localhost:4100/mcp` en dev) et ajoutez l'en-tête
`X-API-Key` avec votre clé.
## Outils exposés
Tous en lecture seule (`readOnlyHint: true`) :
| Outil | Description |
|---|---|
| `crowdlending_get_investisseur` | Profil de l'investisseur lié à la clé (liste des membres si clé « Famille et entreprises ») |
| `crowdlending_get_dashboard` | Synthèse KPI : capital investi, capital en risque, intérêts perçus (filtrable par année), cash |
| `crowdlending_list_investissements` | Liste des investissements, filtrable par statut |
| `crowdlending_get_investissement` | Détail d'un investissement + ses remboursements |
| `crowdlending_list_remboursements` | Historique des remboursements, filtrable par période |
| `crowdlending_list_depots_retraits` | Historique des mouvements de cash |
| `crowdlending_fetch_url` | *(actif seulement si `MCP_ENABLE_FETCH_URL=true`)* Lit une page web (annonce de projet) et en extrait le texte propre — voir ci-dessous |
## Lire une annonce de projet (`crowdlending_fetch_url`)
Désactivé par défaut — à activer avec `MCP_ENABLE_FETCH_URL=true`,
typiquement en développement local uniquement (voir Configuration et
Sécurité plus haut).
Cet outil récupère une page web et en extrait le contenu lisible (titre +
texte principal) via [Readability](https://github.com/mozilla/readability),
la librairie du mode lecture de Firefox — menus, pubs, scripts et bandeaux
cookies sont éliminés automatiquement.
Il ne fait **aucune extraction métier** côté serveur (pas de tentative de
deviner taux/montant/durée par regex) : c'est l'agent qui lit le texte
retourné et en extrait les informations pertinentes dans la conversation,
pour vous les proposer avant toute saisie. Cohérent avec le reste du
serveur : lecture seule, aucune création automatique d'investissement (l'API
v1 n'a pas de capacité d'écriture).
Exemple d'usage : *« Regarde cette page et propose-moi les infos pour créer
l'investissement : https://plateforme.fr/projets/xxx »*.
Garde-fous :
- http/https uniquement, pages HTML uniquement.
- Hôtes locaux/privés bloqués (`localhost`, `127.0.0.1`, plages `10.x`/`172.16-31.x`/`192.168.x`...) —
l'outil ne peut pas cibler votre réseau local, y compris votre propre backend.
- Texte tronqué à 8000 caractères sur les pages très longues (le champ
`truncated` de la réponse l'indique).
## Dépannage
- **`En-tête X-API-Key manquant`** — vérifiez l'argument `--header` et la
variable d'environnement correspondante dans votre config Claude Desktop.
- **« Certains serveurs MCP n'ont pas pu être chargés » / entrée ignorée** —
votre config utilise un bloc `url`/`headers` directement, non supporté par
`claude_desktop_config.json` (voir Développement local plus haut) : il faut
passer par `command: "npx"` + `mcp-remote`.
- **Le serveur reste sur « running » indéfiniment, « Preparing session… » puis
« Could not attach »** — sous Windows, il manque le wrapper `cmd /c` (voir
Développement local). C'est un problème de fond très courant : `npx` y est
un script `.cmd` que `spawn()` ne sait pas exécuter directement, donc le
process est lancé mais ne communique jamais réellement. Symptôme
caractéristique : aucune erreur explicite, juste un blocage silencieux.
- **`Clé API invalide ou révoquée`** — régénérez une clé dans Mon compte →
Clés API et mettez à jour la config.
- **`Impossible de joindre l'API`** — le backend n'est pas démarré, ou
`CROWDLENDING_API_URL` pointe au mauvais endroit (vérifiez le port et le
suffixe `/api/v1`).
- **`Invalid Host` (403)** — l'en-tête `Host` de la requête ne correspond à
aucune valeur de `MCP_ALLOWED_HOSTS`. En local, vérifiez que vous appelez
bien `localhost:4100` (pas `127.0.0.1:4100`, absent de la liste par
défaut — ajoutez-le à `MCP_ALLOWED_HOSTS` si besoin).
- **Session `404` après une longue pause** — la session a expiré après 30
minutes d'inactivité, votre client MCP doit s'y reconnecter (généralement
automatique).
+1684
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "crowdlending-mcp-server",
"version": "0.2.0",
"description": "Serveur MCP (HTTP) pour le portefeuille de crowdlending — expose l'API v1 en lecture seule à un client MCP (Claude Desktop, Claude Code...). Même serveur en développement local et en production distante.",
"type": "module",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js",
"inspect": "npx @modelcontextprotocol/inspector"
},
"engines": {
"node": ">=18"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"@mozilla/readability": "^0.6.0",
"express": "^5.2.1",
"express-rate-limit": "^8.2.1",
"jsdom": "^29.1.1",
"zod": "^3.25.0"
}
}
+241
View File
@@ -0,0 +1,241 @@
#!/usr/bin/env node
/**
* Serveur MCP (HTTP, Streamable HTTP transport) Crowdlending Tracker
*
* Point d'entrée unique, que ce soit en développement local (`npm run dev`,
* comme le backend et le frontend) ou déployé à distance en production
* (mcp.crowdlending.croguennec.net, service `crowdlending-mcp` du
* docker-compose). Un seul modèle de transport, un seul fichier à
* maintenir : les outils eux-mêmes vivent dans tools.js.
*
* AUTHENTIFICATION : aucune clé API fixe côté serveur, contrairement à
* l'ancien serveur stdio. Un même process peut servir plusieurs
* utilisateurs/sessions en parallèle (typiquement un seul en dev local,
* potentiellement plusieurs en déploiement distant) chaque session MCP est
* initialisée à partir de la clé API fournie dans l'en-tête `X-API-Key` de
* la requête HTTP qui l'a créée, puis cette clé est fermée dans le closure
* des outils de CETTE session uniquement (voir createSession). Deux sessions
* ne partagent jamais d'état ni de données.
*
* Connexion depuis Claude Desktop (dev comme prod) : ce fichier expose du
* HTTP pur, mais claude_desktop_config.json n'accepte que des entrées
* command/args la connexion passe donc par le pont mcp-remote
* (https://github.com/geelen/mcp-remote), voir README.md pour la config
* exacte (et le wrapper cmd /c obligatoire sous Windows).
*/
import { randomUUID } from 'node:crypto';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
import rateLimit from 'express-rate-limit';
import { registerDataTools, registerFetchUrlTool } from './tools.js';
const PORT = Number(process.env.PORT || 4100);
const API_BASE = (process.env.CROWDLENDING_API_URL || 'http://localhost:4000/api/v1').replace(/\/$/, '');
// Étiquette facultative pour distinguer plusieurs instances connectées en
// même temps à Claude Desktop (ex. dev local + prod distante). Affiche
// "[DEV]"/"[PROD]" dans le titre de chaque outil et la source exacte en fin
// de description — comme l'ancien CROWDLENDING_LABEL du serveur stdio.
const LABEL = (process.env.MCP_LABEL || '').trim();
// Désactivé par défaut : cet outil lit une URL arbitraire fournie par
// l'appelant, sûr pour un usage perso (vous seul avez la clé API et
// n'atteignez que ce process) mais risqué si le serveur est exposé à des
// tiers non maîtrisés (SSRF). À activer explicitement pour le développement
// local — jamais sur le déploiement distant public (voir docker-compose.yml).
const ENABLE_FETCH_URL = ['1', 'true', 'yes'].includes((process.env.MCP_ENABLE_FETCH_URL || '').toLowerCase());
// Nom(s) d'hôte attendus dans l'en-tête Host — protection anti DNS-rebinding.
// `localhost` est inclus par défaut : nécessaire en développement local
// (connexion directe à localhost:4100) et pour que le HEALTHCHECK Docker
// (qui interroge http://localhost:4100/health depuis l'intérieur du
// container) ne soit pas lui-même bloqué. Sans risque côté externe en
// production : Traefik ne route vers ce service que les requêtes portant le
// Host public configuré sur son router.
const ALLOWED_HOSTS = (process.env.MCP_ALLOWED_HOSTS || 'mcp.crowdlending.croguennec.net,localhost')
.split(',').map((h) => h.trim()).filter(Boolean);
// Durée d'inactivité au-delà de laquelle une session orpheline est fermée
// (client parti sans DELETE explicite — évite une fuite mémoire lente).
const SESSION_IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 min
console.error(`[crowdlending-mcp${LABEL ? `-${LABEL}` : ''}] démarrage — API cible : ${API_BASE}, hôtes autorisés : ${ALLOWED_HOSTS.join(', ')}, fetch_url : ${ENABLE_FETCH_URL ? 'activé' : 'désactivé'}`);
/** Préfixe commun à toutes les lignes de log applicatif. */
const LOG_PREFIX = `[crowdlending-mcp${LABEL ? `-${LABEL}` : ''}]`;
/** Résumé lisible d'une requête JSON-RPC entrante (nom d'outil pour un
* tools/call, méthode sinon), pour le log applicatif jamais la clé API. */
function describeRequest(body) {
if (!body || typeof body !== 'object') return 'requête inconnue';
if (body.method === 'tools/call') return `tools/call ${body.params?.name || '?'}`;
return body.method || 'requête inconnue';
}
/** Ajoute le libellé d'environnement au titre d'un outil (ex. "[PROD]"). */
const withLabel = (title) => LABEL ? `${title} [${LABEL.toUpperCase()}]` : title;
/** Ajoute la source (URL API + libellé) en fin de description. */
const withSource = (description) =>
`${description}\n\nSource de données : ${API_BASE}${LABEL ? ` (environnement : ${LABEL})` : ''}`;
/* Client API : une closure par session, liée à LA clé API de cette
session (jamais un module-level constant). */
function makeApiGet(apiKey) {
return async function apiGet(path, params) {
const url = new URL(API_BASE + path);
if (params) {
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, String(v));
}
}
let res;
try {
res = await fetch(url, {
headers: { 'X-API-Key': apiKey, 'Accept': 'application/json' },
signal: AbortSignal.timeout(15000),
});
} catch (e) {
throw new Error(`Impossible de joindre l'API (${API_BASE}) : ${e.message}`);
}
const text = await res.text();
let body;
try { body = text ? JSON.parse(text) : null; } catch { body = text; }
if (!res.ok) {
const msg = (body && body.error) || res.statusText || 'Requête échouée';
if (res.status === 401) throw new Error(`Clé API invalide ou révoquée (${msg}). Générez-en une nouvelle dans Mon compte → Clés API.`);
if (res.status === 404) throw new Error(`Ressource introuvable : ${msg}`);
throw new Error(`Erreur API (${res.status}) : ${msg}`);
}
return body;
};
}
/* ── Gestion des sessions ─────────────────────────────────────────────── */
// sessionId -> { transport, apiKey, lastActivity }
const sessions = new Map();
function touchSession(sessionId) {
const s = sessions.get(sessionId);
if (s) s.lastActivity = Date.now();
}
setInterval(() => {
const now = Date.now();
for (const [sessionId, s] of sessions.entries()) {
if (now - s.lastActivity > SESSION_IDLE_TIMEOUT_MS) {
console.error(`${LOG_PREFIX} session ${sessionId.slice(0, 8)} inactive depuis plus de 30 min, fermeture.`);
s.transport.close();
sessions.delete(sessionId);
}
}
}, 5 * 60 * 1000).unref();
/** Crée un serveur MCP + transport pour une nouvelle session, lié à `apiKey`. */
async function createSession(apiKey) {
const server = new McpServer({ name: 'crowdlending-mcp' + (LABEL ? `-${LABEL}` : ''), version: '0.2.0' });
registerDataTools(server, { apiGet: makeApiGet(apiKey), withLabel, withSource });
if (ENABLE_FETCH_URL) registerFetchUrlTool(server);
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (sessionId) => {
sessions.set(sessionId, { transport, apiKey, lastActivity: Date.now() });
},
});
transport.onclose = () => {
if (transport.sessionId) sessions.delete(transport.sessionId);
};
await server.connect(transport);
return transport;
}
/* ── Application Express ──────────────────────────────────────────────── */
const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ALLOWED_HOSTS });
// Limite basique anti-abus : une clé compromise ou un client buggé ne doit
// pas pouvoir marteler l'API backend sans frein.
app.use(rateLimit({
windowMs: 60 * 1000,
limit: 60,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Trop de requêtes — réessayez dans une minute.' },
}));
app.get('/health', (req, res) => res.json({ status: 'ok', sessions: sessions.size }));
app.post('/mcp', async (req, res) => {
try {
const sessionId = req.headers['mcp-session-id'];
if (sessionId) {
const session = sessions.get(sessionId);
if (!session) {
res.status(404).json({ jsonrpc: '2.0', error: { code: -32001, message: 'Session inconnue ou expirée' }, id: null });
return;
}
// Garde-fou : si le client envoie une clé API différente de celle qui a
// créé la session, on refuse plutôt que de silencieusement continuer
// avec l'ancienne clé.
const headerKey = req.headers['x-api-key'];
if (headerKey && headerKey !== session.apiKey) {
res.status(403).json({ jsonrpc: '2.0', error: { code: -32002, message: 'Clé API différente de celle ayant initialisé cette session' }, id: null });
return;
}
touchSession(sessionId);
const label = describeRequest(req.body);
const startedAt = Date.now();
await session.transport.handleRequest(req, res, req.body);
console.error(`${LOG_PREFIX} session ${sessionId.slice(0, 8)}${label}${res.statusCode} (${Date.now() - startedAt}ms)`);
return;
}
if (!isInitializeRequest(req.body)) {
res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Requête invalide : aucun identifiant de session fourni' }, id: null });
return;
}
const apiKey = req.headers['x-api-key'];
if (!apiKey) {
res.status(401).json({ jsonrpc: '2.0', error: { code: -32003, message: "En-tête X-API-Key manquant. Générez une clé dans Mon compte → Clés API." }, id: null });
return;
}
const transport = await createSession(apiKey);
const startedAt = Date.now();
await transport.handleRequest(req, res, req.body);
console.error(`${LOG_PREFIX} nouvelle session ${transport.sessionId ? transport.sessionId.slice(0, 8) : '?'} — initialize — ${res.statusCode} (${Date.now() - startedAt}ms)`);
} catch (e) {
console.error(`${LOG_PREFIX} erreur /mcp POST :`, e);
if (!res.headersSent) res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: e.message }, id: null });
}
});
/** GET (flux SSE de notifications) et DELETE (fin de session) délèguent au
* transport existant, identifié par `mcp-session-id` jamais de création
* de session sur ces deux méthodes. */
async function handleExistingSession(req, res) {
const sessionId = req.headers['mcp-session-id'];
const session = sessionId && sessions.get(sessionId);
if (!session) {
res.status(404).json({ error: 'Session inconnue ou expirée' });
return;
}
touchSession(sessionId);
await session.transport.handleRequest(req, res);
}
app.get('/mcp', handleExistingSession);
app.delete('/mcp', handleExistingSession);
app.listen(PORT, '0.0.0.0', () => {
console.error(`${LOG_PREFIX} à l'écoute sur le port ${PORT}`);
});
+327
View File
@@ -0,0 +1,327 @@
/**
* Outils MCP partagés par le serveur unique (server.js, HTTP), que ce soit
* en développement local (npm run dev, localhost:4100) ou déployé à distance
* (mcp.crowdlending.croguennec.net).
*
* Ce module ne contient AUCUNE logique de transport ni d'authentification :
* il reçoit un `apiGet(path, params)` déjà prêt à l'emploi (déjà lié à une
* clé API et une base URL précises) et enregistre les outils sur le
* `McpServer` fourni. Ainsi, le comportement des outils ne peut pas diverger
* selon l'environnement un seul endroit à maintenir.
*
* `registerFetchUrlTool` est séparée de `registerDataTools` et enregistrée
* de façon CONDITIONNELLE par server.js (variable d'env
* `MCP_ENABLE_FETCH_URL`) : cet outil lit une URL arbitraire fournie par
* l'appelant, ce qui est sûr pour un usage perso (vous seul pouvez y
* accéder) mais présente un risque SSRF si exposé à des utilisateurs
* distants non maîtrisés désactivé par défaut, à activer explicitement
* pour le développement local.
*/
import { z } from 'zod';
import { JSDOM } from 'jsdom';
import { Readability } from '@mozilla/readability';
const READ_ONLY_ANNOTATIONS = {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
};
/**
* Enregistre les 6 outils de lecture de données sur `server`.
*
* @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server
* @param {object} ctx
* @param {(path: string, params?: object) => Promise<any>} ctx.apiGet
* Fonction d'appel à l'API v1, déjà authentifiée pour ce serveur/session.
* @param {(title: string) => string} [ctx.withLabel]
* Ajoute un libellé d'environnement au titre d'un outil (ex. "[PROD]").
* Par défaut : identité (pas de libellé).
* @param {(description: string) => string} [ctx.withSource]
* Ajoute la source de données en fin de description.
* Par défaut : identité (pas de source affichée).
*/
export function registerDataTools(server, { apiGet, withLabel = (t) => t, withSource = (d) => d }) {
server.registerTool(
'crowdlending_get_investisseur',
{
title: withLabel('Profil investisseur'),
description: withSource(
"Retourne le profil de l'investisseur associé à la clé API utilisée " +
"(nom, type famille/entreprise, régime fiscal). Utile pour savoir sur " +
"quel portefeuille portent les autres outils. Avec une clé « Famille et " +
"entreprises » (scope agrégé), retourne une liste de profils (un par " +
"membre du foyer) au lieu d'un profil unique — dans ce cas, les autres " +
"outils portent sur l'ensemble des membres, pas un seul."),
inputSchema: {},
annotations: READ_ONLY_ANNOTATIONS,
},
async () => {
try { return toolResult(await apiGet('/investisseur')); }
catch (e) { return toolError(e); }
},
);
server.registerTool(
'crowdlending_get_dashboard',
{
title: withLabel('Synthèse du portefeuille'),
description: withSource(
"Retourne les KPIs du portefeuille : nombre d'investissements, total " +
"investi, capital investi actuel (= capital restant dû sur les prêts en " +
"cours et en défaut, net des remboursements déjà perçus — équivalent au " +
"KPI \"Capital investi\" de l'app), capital en risque (sous-ensemble en " +
"retard/procédure), montant remboursé, intérêts bruts/nets perçus, " +
"capital reçu, total dépôts/retraits. Les montants d'investissements sont " +
"des soldes actuels (photo à aujourd'hui), pas des cumuls par période. " +
"Point d'entrée idéal pour une vue d'ensemble avant d'aller chercher le détail."),
inputSchema: {
annee: z.number().int().optional()
.describe("Filtre les intérêts/capital reçu sur une année (ex. 2026). Omettre pour le cumul total. N'affecte pas le capital investi/en risque, qui sont toujours des soldes actuels."),
},
annotations: READ_ONLY_ANNOTATIONS,
},
async ({ annee }) => {
try { return toolResult(await apiGet('/dashboard', { annee })); }
catch (e) { return toolError(e); }
},
);
server.registerTool(
'crowdlending_list_investissements',
{
title: withLabel('Liste des investissements'),
description: withSource(
"Liste les investissements (prêts participatifs) du portefeuille : " +
"projet, émetteur, plateforme, montant investi, taux, durée, statut. " +
"Filtrable par statut. Ne renvoie pas les remboursements détaillés — " +
"utiliser crowdlending_get_investissement pour le détail d'un projet."),
inputSchema: {
statut: z.enum(['en_cours', 'rembourse', 'en_retard', 'procedure', 'cloture'])
.optional()
.describe("Filtrer par statut. Omettre pour lister tous les investissements."),
},
annotations: READ_ONLY_ANNOTATIONS,
},
async ({ statut }) => {
try { return toolResult(await apiGet('/investissements', { statut })); }
catch (e) { return toolError(e); }
},
);
server.registerTool(
'crowdlending_get_investissement',
{
title: withLabel("Détail d'un investissement"),
description: withSource(
"Retourne le détail complet d'un investissement (identifié par son id, " +
"obtenu via crowdlending_list_investissements), y compris la liste de " +
"ses remboursements réels perçus (capital, intérêts bruts/nets, cashback)."),
inputSchema: {
id: z.number().int().positive().describe("Identifiant de l'investissement"),
},
annotations: READ_ONLY_ANNOTATIONS,
},
async ({ id }) => {
try { return toolResult(await apiGet(`/investissements/${id}`)); }
catch (e) { return toolError(e); }
},
);
server.registerTool(
'crowdlending_list_remboursements',
{
title: withLabel('Liste des remboursements'),
description: withSource(
"Liste les remboursements réels perçus (toutes plateformes confondues), " +
"avec le nom du projet et de la plateforme. Filtrable par période."),
inputSchema: {
date_debut: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional()
.describe('Date de début au format YYYY-MM-DD (incluse)'),
date_fin: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional()
.describe('Date de fin au format YYYY-MM-DD (incluse)'),
},
annotations: READ_ONLY_ANNOTATIONS,
},
async ({ date_debut, date_fin }) => {
try { return toolResult(await apiGet('/remboursements', { date_debut, date_fin })); }
catch (e) { return toolError(e); }
},
);
server.registerTool(
'crowdlending_list_depots_retraits',
{
title: withLabel('Liste des dépôts / retraits'),
description: withSource(
"Liste les mouvements de cash (dépôts et retraits) sur les plateformes " +
"de crowdlending, du plus récent au plus ancien."),
inputSchema: {},
annotations: READ_ONLY_ANNOTATIONS,
},
async () => {
try { return toolResult(await apiGet('/depots-retraits')); }
catch (e) { return toolError(e); }
},
);
}
/** Formate le résultat d'un outil : texte JSON lisible + structuredContent.
* Le protocole MCP exige que `structuredContent` soit un objet JSON (pas un
* tableau brut) les endpoints qui renvoient une liste sont enveloppés
* dans { items: [...] }. */
function toolResult(data) {
const structuredContent = Array.isArray(data) ? { items: data } : data;
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
structuredContent,
};
}
/** Formate une erreur d'outil de façon à ce que l'agent comprenne quoi faire. */
export function toolError(e) {
return {
content: [{ type: 'text', text: `Erreur : ${e.message}` }],
isError: true,
};
}
/* Outil fetch_url (Phase 3) enregistrement conditionnel
Récupère une page web (annonce de projet sur une plateforme, par ex.) et en
extrait le contenu lisible (titre + texte, sans menus/scripts/pubs) via
Readability la même librairie que le mode lecture de Firefox. L'outil ne
fait AUCUNE extraction métier (pas de tentative de deviner taux/montant/
échéance côté serveur) : il fournit le texte propre, et c'est à l'agent
d'en extraire les informations pertinentes dans la conversation, puis de
les proposer à l'utilisateur pour confirmation. Cohérent avec le reste du
serveur : aucune écriture, la création d'un investissement reste un geste
manuel dans l'app. */
const CHARACTER_LIMIT = 8000; // évite de saturer le contexte de l'agent sur une page très longue
const FETCH_TIMEOUT_MS = 15000;
const FETCH_USER_AGENT = 'Mozilla/5.0 (compatible; CrowdlendingMcpServer/0.2; +local-tool)';
/** Garde-fou basique contre le SSRF : un outil qui prend une URL arbitraire
* en entrée ne doit pas pouvoir taper sur le réseau local de l'utilisateur
* (y compris son propre backend). Vérif sur le nom d'hôte littéral ne
* résout pas le DNS, donc pas une protection anti-rebinding DNS complète,
* mais bloque les cas évidents (localhost, IP privées écrites en clair). */
function isBlockedHost(hostname) {
const h = hostname.toLowerCase();
if (h === 'localhost' || h === '0.0.0.0' || h === '::1' || h.endsWith('.local')) return true;
const ipv4 = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (ipv4) {
const [a, b] = ipv4.slice(1).map(Number);
if (a === 127 || a === 10 || a === 0) return true;
if (a === 192 && b === 168) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 169 && b === 254) return true;
}
return false;
}
/** Enregistre `crowdlending_fetch_url` sur `server`. À n'appeler que si
* `MCP_ENABLE_FETCH_URL` est activé côté server.js voir note en tête de
* fichier. */
export function registerFetchUrlTool(server) {
server.registerTool(
'crowdlending_fetch_url',
{
title: 'Lire une page web',
description:
"Récupère une page web (ex. annonce d'un projet sur une plateforme de " +
"crowdlending) et en extrait le contenu lisible (titre + texte principal, " +
"débarrassé du menu/CSS/pubs). Ne fait AUCUNE écriture et ne crée rien " +
"dans l'app — l'agent doit extraire lui-même les informations utiles du " +
"texte retourné (taux, montant, durée, émetteur...) et les proposer à " +
"l'utilisateur pour confirmation avant toute saisie manuelle dans l'app " +
"(l'API n'a pas de capacité d'écriture). Le texte est tronqué à " +
`${CHARACTER_LIMIT} caractères sur les pages très longues.`,
inputSchema: {
url: z.string().url().describe("URL de la page à lire (http/https uniquement)"),
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
},
async ({ url }) => {
try {
let parsed;
try { parsed = new URL(url); }
catch { throw new Error(`URL invalide : ${url}`); }
if (!['http:', 'https:'].includes(parsed.protocol)) {
throw new Error(`Protocole non autorisé (${parsed.protocol}) — http/https uniquement.`);
}
if (isBlockedHost(parsed.hostname)) {
throw new Error(`Hôte non autorisé (${parsed.hostname}) — cet outil ne peut pas cibler le réseau local.`);
}
let res;
try {
res = await fetch(parsed, {
headers: {
'User-Agent': FETCH_USER_AGENT,
'Accept': 'text/html,application/xhtml+xml',
'Accept-Language': 'fr-FR,fr;q=0.9',
},
redirect: 'follow',
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
} catch (e) {
// Node/undici masque souvent la vraie cause derrière un message générique
// "fetch failed" — la cause réelle (DNS, TLS, connexion refusée...) est
// dans e.cause, à remonter explicitement pour un diagnostic utile.
const cause = e.cause ? ` (${e.cause.code || e.cause.message || e.cause})` : '';
throw new Error(`Impossible de récupérer la page : ${e.message}${cause}`);
}
if (!res.ok) throw new Error(`La page a répondu avec le statut ${res.status} ${res.statusText}`.trim());
const contentType = res.headers.get('content-type') || '';
if (!contentType.includes('html')) {
throw new Error(`Contenu non HTML (${contentType || 'type inconnu'}) — cet outil ne lit que des pages web.`);
}
const html = await res.text();
const dom = new JSDOM(html, { url: parsed.toString() });
const article = new Readability(dom.window.document).parse();
const title = article?.title || dom.window.document.title || null;
let text = (article?.textContent || dom.window.document.body?.textContent || '')
.replace(/[ \t]+/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim();
const fullLength = text.length;
let truncated = false;
if (text.length > CHARACTER_LIMIT) {
text = text.slice(0, CHARACTER_LIMIT);
truncated = true;
}
if (!text) throw new Error("Aucun contenu lisible n'a pu être extrait de cette page.");
const output = {
url: parsed.toString(),
title,
site_name: article?.siteName || null,
excerpt: article?.excerpt || null,
text,
length: fullLength,
truncated,
};
return {
content: [{ type: 'text', text: JSON.stringify(output, null, 2) }],
structuredContent: output,
};
} catch (e) { return toolError(e); }
},
);
}