Compare commits

...

29 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
71 changed files with 11170 additions and 305 deletions
+218 -1
View File
@@ -1,5 +1,5 @@
# MEMORY.md — Crowdlending Tracker
*Dernière mise à jour: 2026-07-03 (session 8)*
*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
@@ -499,6 +500,222 @@ const isBonus = BONUS_VALUES.includes(form.investissement_id);
- **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"
},
+229 -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);
@@ -2086,4 +2161,144 @@ console.log('[DB] Migrations 2FA OK');
}
}
// ── 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();
}
+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;
+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),
);
+336 -31
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, () => {});
@@ -139,6 +333,10 @@ router.post('/apply', (req, res, next) => {
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();
@@ -159,6 +357,10 @@ 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');
@@ -174,7 +376,7 @@ router.post('/apply', (req, res, next) => {
AND type = ? AND ABS(montant - ?) < 0.005
LIMIT 1
`).get(req.investisseur.id, plateformeId, dateOperation, type, montant);
if (dup) { duplicates++; continue; }
if (dup && rowDecision !== 'accept') { duplicates++; continue; }
db.prepare(`
INSERT INTO depots_retraits
@@ -203,7 +405,7 @@ router.post('/apply', (req, res, next) => {
WHERE investisseur_id = ? AND plateforme_id = ? AND nom_projet = ? AND date_souscription = ?
LIMIT 1
`).get(req.investisseur.id, plateformeId, nomProjet, dateSouscription);
if (dup) { duplicates++; continue; }
if (dup && rowDecision !== 'accept') { duplicates++; continue; }
db.prepare(`
INSERT INTO investissements
@@ -247,7 +449,7 @@ router.post('/apply', (req, res, next) => {
AND ABS(capital - ?) < 0.005 AND ABS(interets_bruts - ?) < 0.005
LIMIT 1
`).get(investissementId, dateRemb, capital, bruts);
if (dup) { duplicates++; continue; }
if (dup && rowDecision !== 'accept') { duplicates++; continue; }
db.prepare(`
INSERT INTO remboursements
@@ -261,25 +463,39 @@ router.post('/apply', (req, res, next) => {
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) — doublon, pas une erreur
if (r.changes === 0) { duplicates++; continue; }
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 (?, ?, ?, ?)
@@ -305,6 +521,31 @@ 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) {
@@ -344,6 +585,43 @@ router.post('/apply', (req, res, next) => {
} 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); }
});
/**
* POST /api/imports/dossier
* Importe un dossier investissement complet (format d'export natif).
@@ -380,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);
@@ -462,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 = ?,
@@ -471,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,
);
@@ -528,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';
}
+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 {
+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;
+65
View File
@@ -679,6 +679,16 @@ router.delete('/:id', (req, res, next) => {
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');
@@ -690,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 {
+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;
+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 dû 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;
+21 -2
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';
@@ -88,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);
@@ -117,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);
@@ -139,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);
@@ -148,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 />} />
+2 -2
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, {
@@ -65,7 +65,7 @@ export const api = {
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();
}),
+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" />
+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 , 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>
)}
+52
View File
@@ -12,6 +12,7 @@ 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 ──────────────────────────────────────────── */
@@ -397,6 +398,14 @@ export default function DepotsRetraits() {
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);
@@ -681,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 */
@@ -966,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>
@@ -981,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' }}>
+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}
+672
View File
@@ -4,7 +4,11 @@ import PasswordInput from '../components/PasswordInput.jsx';
import { useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext.jsx';
import { useUi } from '../context/UiContext.jsx';
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import Modal from '../components/Modal.jsx';
import { api } from '../api.js';
import { memberLabel } from '../utils/format.js';
import { withDevOverrides } from '../utils/devOverrides.js';
/* ── Icônes nav ─────────────────────────────────────────────── */
function IconUser() {
@@ -13,6 +17,15 @@ function IconUser() {
function IconLock() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>;
}
function IconKey() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="7.5" cy="15.5" r="5.5"/><path d="M21 2l-9.6 9.6"/><path d="M15.5 7.5l3 3L22 7l-3-3"/></svg>;
}
function IconTrash() {
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 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>;
}
function IconServer() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="7" rx="1.5"/><rect x="2" y="14" width="20" height="7" rx="1.5"/><path d="M6 6.5h.01"/><path d="M6 17.5h.01"/></svg>;
}
/* ── Dropdown custom style Finary ────────────────────────────── */
const LANGUES = [
@@ -782,6 +795,661 @@ function DeleteAccountSection() {
);
}
/* ── Clés API ─────────────────────────────────────────────────
Permet de générer des clés pour un accès programmatique (API publique
en lecture seule, serveur MCP local...). Chaque clé est nommée par
l'utilisateur et rattachée à un investisseur précis. La valeur en clair
n'est affichée qu'une seule fois, juste après la création. ─────────── */
function NewApiKeyModal({ open, onClose, onCreated, investisseurs }) {
const [nom, setNom] = useState('');
const [investisseurId, setInvestisseurId] = useState(investisseurs[0]?.id || '');
const [busy, setBusy] = useState(false);
const [err, setErr] = useState(null);
// La clé "Famille et entreprises" (scope_all) n'est autorisée que depuis le
// profil principal — cohérent avec l'enforcement côté serveur (routes/apiKeys.js).
// Si aucun principal n'est trouvé (ne devrait pas arriver), on masque
// simplement l'option plutôt que de proposer un choix qui échouera au submit.
const principal = investisseurs.find(i => i.is_principal);
useEffect(() => {
if (open) {
setNom('');
setInvestisseurId(investisseurs[0]?.id || '');
setErr(null);
}
}, [open, investisseurs]);
const isScopeAll = investisseurId === 'all';
const submit = async (e) => {
e.preventDefault();
if (!nom.trim()) return setErr('Le nom de la clé est requis');
if (!investisseurId) return setErr('Sélectionnez un investisseur');
setBusy(true);
setErr(null);
try {
const payload = isScopeAll
? { nom: nom.trim(), investisseur_id: principal.id, scope_all: true }
: { nom: nom.trim(), investisseur_id: Number(investisseurId) };
const created = await api.post('/api-keys', payload);
onCreated(created);
} catch (e) { setErr(e.message); }
finally { setBusy(false); }
};
return (
<Modal open={open} title="Nouvelle clé API" onClose={onClose}>
<form onSubmit={submit} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{err && <div className="error">{err}</div>}
<div>
<label>Nom de la clé</label>
<input value={nom} onChange={e => setNom(e.target.value)}
placeholder="ex : MCP local, Script import…" autoFocus />
</div>
<div>
<label>Investisseur</label>
<select value={investisseurId} onChange={e => setInvestisseurId(e.target.value)}>
{investisseurs.map(i => (
<option key={i.id} value={i.id}>{memberLabel(i)}</option>
))}
{principal && <option value="all">Famille et entreprises</option>}
</select>
</div>
<p className="text-muted" style={{ margin: 0, fontSize: 'var(--fs-sm)' }}>
{isScopeAll
? "La clé donnera un accès en lecture seule agrégé à tous les membres du foyer (comme la vue « Famille et entreprises » de l'app). Elle ne sera affichée en clair qu'une seule fois."
: "La clé donne un accès en lecture seule aux données de cet investisseur. Elle ne sera affichée en clair qu'une seule fois."}
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 4 }}>
<button type="button" className="ghost" onClick={onClose}>Annuler</button>
<button type="submit" className="primary" disabled={busy}>
{busy ? 'Création…' : 'Créer la clé'}
</button>
</div>
</form>
</Modal>
);
}
/** URL absolue de la doc Swagger, déduite de VITE_API_URL comme le reste de l'app
* (ex: pages/settings/PlateformesSection.jsx). En dev via proxy Vite, VITE_API_URL
* n'est pas défini et l'URL reste relative — ça fonctionne aussi bien. */
const DOCS_URL = (import.meta.env.VITE_API_URL || '/api').replace(/\/api$/, '') + '/api/docs';
function RevealApiKeyModal({ apiKey, onClose }) {
const [copied, setCopied] = useState(false);
const [copiedCurl, setCopiedCurl] = useState(false);
if (!apiKey) return null;
const curlCmd = `curl -H "X-API-Key: ${apiKey.key}" <URL_DE_VOTRE_API>/api/v1/dashboard`;
const copy = async () => {
try {
await navigator.clipboard.writeText(apiKey.key);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch { /* clipboard indisponible, l'utilisateur peut sélectionner manuellement */ }
};
const copyCurl = async () => {
try {
await navigator.clipboard.writeText(curlCmd);
setCopiedCurl(true);
setTimeout(() => setCopiedCurl(false), 2000);
} catch { /* clipboard indisponible */ }
};
return (
<Modal open={!!apiKey} title="Clé API créée" onClose={onClose}>
<p style={{ margin: '0 0 12px', fontSize: 'var(--fs-sm)' }}>
Copiez cette clé maintenant : elle ne sera plus jamais affichée en entier.
</p>
<div style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '10px 12px',
borderRadius: 8, border: '1px solid var(--border)', background: 'var(--surface-2, #f9fafb)',
fontFamily: 'monospace', fontSize: 13, wordBreak: 'break-all',
}}>
<span style={{ flex: 1 }}>{apiKey.key}</span>
<button type="button" className="ghost" onClick={copy} style={{ flexShrink: 0 }}>
{copied ? 'Copié ✓' : 'Copier'}
</button>
</div>
<div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid var(--border)' }}>
<h4 style={{ margin: '0 0 8px', fontSize: 'var(--fs-sm)' }}>Tester votre clé</h4>
<p style={{ margin: '0 0 8px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
Le plus simple : ouvrez la <a href={DOCS_URL} target="_blank" rel="noreferrer">documentation interactive (Swagger)</a>,
cliquez sur « Authorize » et collez la clé ci-dessus, puis testez un endpoint (ex. <code>GET /dashboard</code>).
</p>
<p style={{ margin: '0 0 6px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
Ou en ligne de commande (remplacez <code>&lt;URL_DE_VOTRE_API&gt;</code> par l'adresse de votre backend,
ex. <code>http://localhost:4000</code> en dev) :
</p>
<div style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '8px 10px',
borderRadius: 8, border: '1px solid var(--border)', background: 'var(--surface-2, #f9fafb)',
fontFamily: 'monospace', fontSize: 12, wordBreak: 'break-all',
}}>
<span style={{ flex: 1 }}>{curlCmd}</span>
<button type="button" className="ghost" onClick={copyCurl} style={{ flexShrink: 0 }}>
{copiedCurl ? 'Copié ' : 'Copier'}
</button>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 16 }}>
<button type="button" className="primary" onClick={onClose}>J'ai copié la clé</button>
</div>
</Modal>
);
}
function fmtKeyDate(iso) {
if (!iso) return '—';
return new Date(iso.replace(' ', 'T') + 'Z').toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
/** Modale d'avertissement avant suppression définitive — affichée uniquement
* quand la clé est encore active (une clé révoquée ne sert déjà plus à rien,
* pas besoin d'avertissement dans ce cas). */
function DeleteApiKeyModal({ apiKey, onCancel, onConfirm, busy }) {
if (!apiKey) return null;
return (
<Modal open={!!apiKey} title="Supprimer une clé active ?" onClose={onCancel}>
<p style={{ margin: '0 0 8px', fontSize: 'var(--fs-sm)' }}>
La clé « {apiKey.nom} » est encore <strong>active</strong>. La supprimer coupera immédiatement l'accès
à toute application qui l'utilise (API, serveur MCP...), et cette action est irréversible.
</p>
<p style={{ margin: 0, fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
Si vous voulez juste bloquer l'accès sans supprimer la clé, préférez « Révoquer ».
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 16 }}>
<button type="button" className="ghost" onClick={onCancel} disabled={busy}>Annuler</button>
<button type="button" className="primary" onClick={onConfirm} disabled={busy}
style={{ background: 'var(--danger,#dc2626)', borderColor: 'var(--danger,#dc2626)' }}>
{busy ? 'Suppression' : 'Supprimer définitivement'}
</button>
</div>
</Modal>
);
}
function ApiKeysSection() {
const { investisseurs } = useInvestisseur();
const [keys, setKeys] = useState([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState(null);
const [showNew, setShowNew] = useState(false);
const [revealKey, setRevealKey] = useState(null);
const [busyId, setBusyId] = useState(null);
const [deleteTarget, setDeleteTarget] = useState(null); // clé active en attente de confirmation
const load = async () => {
try {
setLoading(true);
const data = await api.get('/api-keys');
setKeys(data);
} catch (e) { setErr(e.message); }
finally { setLoading(false); }
};
useEffect(() => { load(); }, []);
const revoke = async (id) => {
if (!window.confirm('Révoquer cette clé ? Toute application qui l\'utilise perdra immédiatement l\'accès.')) return;
setBusyId(id);
try {
await api.del(`/api-keys/${id}`);
await load();
} catch (e) { setErr(e.message); }
finally { setBusyId(null); }
};
const purge = async (id) => {
setBusyId(id);
try {
await api.del(`/api-keys/${id}/purge`);
setDeleteTarget(null);
await load();
} catch (e) { setErr(e.message); }
finally { setBusyId(null); }
};
/** Clé active → avertissement avant suppression. Clé révoquée → suppression directe. */
const handleDeleteClick = (k) => {
if (k.revoked_at) purge(k.id);
else setDeleteTarget(k);
};
return (
<div className="card" style={{ marginTop: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<h3 style={{ margin: 0 }}>Clés API</h3>
<button className="primary" onClick={() => setShowNew(true)} disabled={!investisseurs.length}>
+ Nouvelle clé
</button>
</div>
<p className="text-muted" style={{ margin: '0 0 16px', fontSize: 'var(--fs-sm)' }}>
Utilisées pour un accès programmatique en lecture seule (API publique, serveur MCP local...). Vous pouvez créer plusieurs clés, une par usage.
{' '}Consultez la <a href={DOCS_URL} target="_blank" rel="noreferrer">documentation de l'API (Swagger)</a> pour la liste des endpoints disponibles.
</p>
{err && <div className="error" style={{ marginBottom: 12 }}>{err}</div>}
{!loading && keys.length === 0 && (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Aucune clé API pour l'instant.</p>
)}
{keys.length > 0 && (
<table style={{ width: '100%' }}>
<thead>
<tr>
<th>Nom</th>
<th>Investisseur</th>
<th>Clé</th>
<th>Créée le</th>
<th>Dernière utilisation</th>
<th>Statut</th>
<th></th>
</tr>
</thead>
<tbody>
{keys.map(k => (
<tr key={k.id}>
<td>{k.nom}</td>
<td>{k.scope_all ? 'Famille et entreprises' : k.investisseur_nom}</td>
<td style={{ fontFamily: 'monospace', fontSize: 12 }}>{k.key_prefix}</td>
<td>{fmtKeyDate(k.created_at)}</td>
<td>{fmtKeyDate(k.last_used_at)}</td>
<td>
{k.revoked_at
? <span style={{ fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 10, background: 'var(--danger-bg, #fef2f2)', color: 'var(--danger, #dc2626)' }}>Révoquée</span>
: <span style={{ fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 10, background: 'var(--success-bg, #f0fdf4)', color: 'var(--success, #16a34a)' }}>Active</span>}
</td>
<td>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 4 }}>
{!k.revoked_at && (
<button className="ghost" onClick={() => revoke(k.id)} disabled={busyId === k.id}
style={{ color: 'var(--danger,#dc2626)' }}>
Révoquer
</button>
)}
<button className="ghost" onClick={() => handleDeleteClick(k)} disabled={busyId === k.id}
title="Supprimer définitivement"
style={{ color: 'var(--text-muted)', padding: '6px 8px' }}>
<IconTrash />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
<NewApiKeyModal
open={showNew}
onClose={() => setShowNew(false)}
investisseurs={investisseurs}
onCreated={(created) => { setShowNew(false); setRevealKey(created); load(); }}
/>
<RevealApiKeyModal apiKey={revealKey} onClose={() => setRevealKey(null)} />
<DeleteApiKeyModal
apiKey={deleteTarget}
busy={busyId === deleteTarget?.id}
onCancel={() => setDeleteTarget(null)}
onConfirm={() => purge(deleteTarget.id)}
/>
</div>
);
}
/* ── Serveur MCP ──────────────────────────────────────────────
Guide pas-à-pas pour connecter Claude Desktop au serveur MCP
(mcp-server/ à la racine du projet, un seul modèle url+headers en HTTP,
que ce soit en développement local ou déployé à distance en prod — voir
mcp-server/README.md). Le JSON de config est généré côté client à partir
des champs ci-dessous ; la clé API saisie ici reste uniquement en mémoire
du navigateur, elle n'est jamais envoyée au backend — seulement utilisée
pour composer l'aperçu à copier. ── */
/** Devine une URL de serveur MCP raisonnable selon l'environnement courant :
* en dev (Vite sur :5173 ou localhost), le serveur MCP tourne en local sur
* son port par défaut (npm run dev, :4100). En prod, il s'agit du
* sous-domaine dédié mcp.<domaine de l'app>, déjà déployé en continu. */
function guessMcpUrl() {
if (typeof window === 'undefined') return 'http://localhost:4100/mcp';
const { hostname, port } = window.location;
if (port === '5173' || hostname === 'localhost' || hostname === '127.0.0.1') {
return 'http://localhost:4100/mcp';
}
return `https://mcp.${hostname}/mcp`;
}
/** Détecte automatiquement l'environnement ('dev' ou 'prod') à partir de
* l'URL du serveur MCP : localhost/IP locale → dev, tout le reste → prod.
* Best-effort — reste modifiable manuellement pour les cas particuliers. */
function detectLabelFromUrl(url) {
if (!url) return 'prod';
let hostname;
try { hostname = new URL(url).hostname.toLowerCase(); }
catch { hostname = url.toLowerCase(); }
const isLocal = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'
|| hostname.endsWith('.local');
return (isLocal || hostname.includes('dev')) ? 'dev' : 'prod';
}
function CopyBlock({ text }) {
const [copied, setCopied] = useState(false);
const copy = async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch { /* clipboard indisponible */ }
};
return (
<div style={{
display: 'flex', alignItems: 'flex-start', gap: 8, padding: '10px 12px',
borderRadius: 8, border: '1px solid var(--border)', background: 'var(--surface-2, #f9fafb)',
fontFamily: 'monospace', fontSize: 12,
}}>
<pre style={{ flex: 1, margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>{text}</pre>
<button type="button" className="ghost" onClick={copy} style={{ flexShrink: 0 }}>
{copied ? 'Copié ✓' : 'Copier'}
</button>
</div>
);
}
function McpServerSection({ goToApiKeys }) {
const navigate = useNavigate();
const [mcpUrl, setMcpUrl] = useState(guessMcpUrl());
const [apiKey, setApiKey] = useState('');
const [manualLabel, setManualLabel] = useState(null); // null = auto-détecté depuis mcpUrl, sinon override manuel
const [debugFlag, setDebugFlag] = useState(false); // ajoute --debug : génère un fichier mcp-server-<nom>.log dédié (voir Dépannage)
const [systemCaFlag, setSystemCaFlag] = useState(true); // ajoute NODE_OPTIONS=--use-system-ca : contourne un antivirus/proxy qui intercepte le HTTPS (voir Dépannage) — coché par défaut, ne s'applique qu'en prod (voir envBlock)
const mcpUrlEditedRef = useRef(false); // true dès que l'utilisateur touche le champ — n'écrase plus la valeur saisie
// Pré-remplit avec l'URL renseignée par l'admin (Administration → Général)
// si elle existe, plutôt que de laisser la seule devinette basée sur
// l'hôte courant (utile notamment quand l'app est servie derrière un nom
// de domaine différent du sous-domaine mcp.<hostname> par défaut).
useEffect(() => {
fetch('/api/app-info').then(r => r.json()).then(d => {
// En dev local, la base est régulièrement une copie de la prod : la
// valeur stockée pointerait encore vers mcp.<domaine de prod> tant
// qu'on ne la corrige pas à la main — voir utils/devOverrides.js.
const info = withDevOverrides(d);
if (info.mcpUrl && !mcpUrlEditedRef.current) setMcpUrl(info.mcpUrl);
}).catch(() => {});
}, []);
const detectedLabel = detectLabelFromUrl(mcpUrl);
const label = manualLabel ?? detectedLabel;
const isLocal = label === 'dev';
const serverKey = `crowdlending-${label}`;
// claude_desktop_config.json n'a pas de champ url/headers natif : on passe
// par mcp-remote (https://github.com/geelen/mcp-remote), un pont stdio↔HTTP
// que Claude Desktop lance comme n'importe quel serveur "command". La clé
// API passe en variable d'environnement plutôt que directement dans args
// (bug connu de Claude Desktop Windows qui tronque les valeurs à espaces).
const envVarName = `${label.toUpperCase()}_API_KEY`;
const mcpRemoteArgs = [
'-y', 'mcp-remote', mcpUrl, '--header', `X-API-Key:\${${envVarName}}`,
...(debugFlag ? ['--debug'] : []),
];
// Sur Windows, npx est en réalité npx.cmd (un script) : child_process.spawn,
// utilisé par Claude Desktop, ne sait pas l'exécuter directement sans passer
// par l'interpréteur de commandes — le serveur reste bloqué sur "running"
// sans jamais répondre. Il faut donc l'appeler via cmd /c. Sans risque sur
// macOS/Linux, qui n'ont pas ce problème (npx s'exécute nativement).
const isWindows = typeof navigator !== 'undefined' && /win/i.test(navigator.platform || navigator.userAgent || '');
const envBlock = {
[envVarName]: apiKey || '<VOTRE_CLE_API>',
// Uniquement pertinent pour une URL distante (prod) : force Node à utiliser
// le magasin de certificats du système plutôt que le sien, pour accepter
// les connexions re-signées par un antivirus/proxy à inspection HTTPS
// (Avast, Kaspersky, proxy d'entreprise…) — voir Dépannage.
...(!isLocal && systemCaFlag ? { NODE_OPTIONS: '--use-system-ca' } : {}),
};
const configJson = JSON.stringify({
mcpServers: {
[serverKey]: isWindows
? { command: 'cmd', args: ['/c', 'npx', ...mcpRemoteArgs], env: envBlock }
: { command: 'npx', args: mcpRemoteArgs, env: envBlock },
},
}, null, 2);
return (
<div className="card" style={{ marginTop: 20 }}>
<h3 style={{ margin: '0 0 4px' }}>Serveur MCP</h3>
<p className="text-muted" style={{ margin: '0 0 20px', fontSize: 'var(--fs-sm)' }}>
Permet à Claude Desktop (ou tout client MCP) de consulter votre portefeuille en lecture seule.
Un seul modèle de connexion, en développement local comme en production : une URL de serveur MCP
et votre clé API personnelle voir <code>mcp-server/README.md</code> pour le détail.
</p>
<h4 style={{ margin: '0 0 6px', fontSize: 'var(--fs-sm)' }}>1. Créer une clé API dédiée</h4>
<p style={{ margin: '0 0 10px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
Utilisez une clé distincte pour le serveur MCP (ex. nommée « MCP Desktop »), pour pouvoir la
révoquer indépendamment des autres usages.
</p>
{goToApiKeys && (
<button type="button" className="ghost" onClick={goToApiKeys} style={{ marginBottom: 20 }}>
Aller créer une clé
</button>
)}
<h4 style={{ margin: '0 0 10px', fontSize: 'var(--fs-sm)' }}>2. Renseigner les paramètres</h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginBottom: 20, maxWidth: 520 }}>
<div>
<label>URL du serveur MCP</label>
<input value={mcpUrl} onChange={e => { mcpUrlEditedRef.current = true; setMcpUrl(e.target.value); }} />
</div>
<div>
<label>Clé API</label>
<input value={apiKey} onChange={e => setApiKey(e.target.value)}
placeholder="clk_live_… (collée ici juste pour générer l'aperçu ci-dessous, non envoyée)" />
</div>
<div>
<label>Environnement détecté</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 4 }}>
<span style={{
fontSize: 12, fontWeight: 600, padding: '3px 10px', borderRadius: 10,
background: label === 'dev' ? 'var(--warning-bg, #fffbeb)' : 'var(--success-bg, #f0fdf4)',
color: label === 'dev' ? 'var(--warning-text, #92400e)' : 'var(--success, #16a34a)',
}}>
{label.toUpperCase()}
</span>
<span style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
{manualLabel === null
? <>déduit de l'URL ci-dessus (localhost → dev, sinon prod)</>
: <>forcé manuellement</>}
</span>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 8, fontWeight: 400, fontSize: 'var(--fs-sm)', cursor: 'pointer' }}>
<input type="checkbox" checked={manualLabel !== null}
onChange={e => setManualLabel(e.target.checked ? detectedLabel : null)}
style={{ width: 'auto' }} />
Forcer manuellement (URL ambiguë, ex. domaine de test sans « dev » dans le nom)
</label>
{manualLabel !== null && (
<select value={manualLabel} onChange={e => setManualLabel(e.target.value)} style={{ marginTop: 8 }}>
<option value="dev">dev</option>
<option value="prod">prod</option>
</select>
)}
</div>
</div>
{isLocal ? (
<p style={{ margin: '-8px 0 20px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
En développement, ce serveur doit tourner sur votre machine pour que l'URL ci-dessus réponde :
<code style={{ display: 'block', margin: '6px 0', padding: '6px 8px', borderRadius: 6, background: 'var(--surface-2, #f9fafb)' }}>
cd mcp-server &amp;&amp; npm install &amp;&amp; npm run dev
</code>
comme pour le backend et le frontend. Laissez ce terminal ouvert tant que vous utilisez Claude Desktop.
</p>
) : (
<p style={{ margin: '-8px 0 20px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
En production, le serveur tourne déjà en continu (service <code>crowdlending-mcp</code>) aucune
action nécessaire au-delà de renseigner l'URL et votre clé API.
</p>
)}
{isLocal && (
<p style={{ margin: '0 0 20px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
Claude Desktop peut se connecter à plusieurs serveurs MCP en même temps : pour avoir aussi votre
portefeuille en production accessible depuis Claude Desktop, répétez ces étapes une deuxième fois
avec l'URL de production (et une clé API distincte) la clé de config
(<code>{serverKey}</code> ci-dessous) s'ajuste automatiquement, Claude Desktop ne confondra jamais
les deux connexions.
</p>
)}
<h4 style={{ margin: '0 0 6px', fontSize: 'var(--fs-sm)' }}>3. Copier la configuration</h4>
<p style={{ margin: '0 0 10px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
Dans Claude Desktop : Réglages → Développeur → Serveurs MCP locaux → <strong>Modifier la config</strong>.
Selon l'installation (même téléchargée directement depuis anthropic.com l'origine ne garantit
rien), Claude Desktop peut être packagé en MSIX et virtualiser ce fichier : le bouton ouvre parfois
une copie sous <code>...\Packages\Claude_*\LocalCache\Roaming\Claude\claude_desktop_config.json</code>
alors que l'app tourne réellement avec <code>%APPDATA%\Claude\claude_desktop_config.json</code> (ou
l'inverse). Si vos outils <code>crowdlending_*</code> n'apparaissent jamais après configuration,
vérifiez les <strong>deux emplacements</strong> et éditez celui qui correspond au dossier
<code>logs\mcp.log</code> se met réellement à jour quand vous relancez l'app (voir Dépannage
ci-dessous). Si le fichier contient déjà une clé <code>"mcpServers"</code>, ajoutez-y seulement
l'entrée <code>"{serverKey}"</code> sans écraser le reste ; sinon collez le bloc entier.
</p>
{isWindows && (
<p style={{ margin: '0 0 10px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
La config ci-dessous passe par <code>cmd /c npx</code> plutôt que <code>npx</code> directement :
nécessaire sous Windows, Claude Desktop ne sait pas lancer <code>npx</code> (script
<code>.cmd</code>) sans passer par l'interpréteur de commandes — sinon le serveur reste bloqué
sur « running » sans jamais répondre.
</p>
)}
<img
src="/mcp/claude-desktop-developer-settings.png"
alt="Claude Desktop — Réglages → Développeur → Serveurs MCP locaux → Modifier la config"
style={{ width: '100%', maxWidth: 520, borderRadius: 8, border: '1px solid var(--border)', display: 'block', margin: '0 auto 16px' }}
onError={(e) => { e.currentTarget.style.display = 'none'; }}
/>
<div style={{ margin: '0 0 10px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontWeight: 400, fontSize: 'var(--fs-sm)', cursor: 'pointer' }}>
<input type="checkbox" checked={debugFlag} onChange={e => setDebugFlag(e.target.checked)} style={{ width: 'auto' }} />
Ajouter <code>--debug</code>
</label>
<p style={{ margin: '2px 0 0 22px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
Recommandé en cas de problème — voir Dépannage ci-dessous.
</p>
</div>
{!isLocal && (
<div style={{ margin: '0 0 10px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontWeight: 400, fontSize: 'var(--fs-sm)', cursor: 'pointer' }}>
<input type="checkbox" checked={systemCaFlag} onChange={e => setSystemCaFlag(e.target.checked)} style={{ width: 'auto' }} />
Ajouter <code>NODE_OPTIONS=--use-system-ca</code>
</label>
<p style={{ margin: '2px 0 0 22px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
Coché par défaut : nécessaire si un antivirus ou un proxy intercepte le HTTPS — voir Dépannage
ci-dessous. Décochez uniquement si vous savez que ce n'est pas votre cas.
</p>
</div>
)}
<CopyBlock text={configJson} />
<h4 style={{ margin: '20px 0 6px', fontSize: 'var(--fs-sm)' }}>4. Redémarrer Claude Desktop</h4>
<p style={{ margin: '0 0 10px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
Quittez complètement l'application (pas juste fermer la fenêtre) puis rouvrez-la.
</p>
<h4 style={{ margin: '0 0 6px', fontSize: 'var(--fs-sm)' }}>5. Vérifier</h4>
<p style={{ margin: '0 0 10px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
Demandez à Claude : « <strong style={{ color: 'var(--text)' }}>Peux-tu me lister les outils auxquels
tu as accès ?</strong> » — c'est la façon la plus fiable de vérifier que la connexion fonctionne
vraiment (plutôt qu'une question sur vos données, qui peut échouer pour d'autres raisons même si la
connexion est bonne). Les outils <code>crowdlending_*</code> doivent apparaître dans sa réponse
{isLocal
? <> (6, ou 7 si <code>crowdlending_fetch_url</code> est activé voir <code>mcp-server/README.md</code>)</>
: ' (6 au total)'}.
</p>
<p style={{ margin: '0 0 20px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
Une fois la connexion confirmée, consultez la FAQ « Comment utiliser le serveur MCP » pour des
exemples de questions et le détail de chaque fonction.
</p>
<button type="button" className="ghost" onClick={() => navigate('/aide?section=faq')} style={{ marginTop: -12, marginBottom: 20 }}>
Voir la FAQ
</button>
<h4 style={{ margin: '0 0 6px', fontSize: 'var(--fs-sm)' }}>6. Dépannage</h4>
<p style={{ margin: '0 0 4px', fontSize: 'var(--fs-sm)', fontWeight: 600 }}>
Le serveur reste sur « running » indéfiniment, aucun outil n'apparaît, aucune erreur visible
</p>
<p style={{ margin: '0 0 14px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
Cause la plus fréquente sous Windows, même avec <code>cmd /c</code> déjà en place : <code>npx</code>
recontacte le registre npm (<code>registry.npmjs.org</code>) à chaque lancement pour vérifier la
version, et un antivirus ou un proxy avec inspection HTTPS (Avast, Kaspersky, ESET, proxy
d'entreprise) fait échouer cette requête avec une erreur de certificat invisible depuis Claude
Desktop, qui attend simplement une réponse jamais reçue jusqu'à expirer au bout d'une minute.
Confirmez en cherchant <code>UNABLE_TO_VERIFY_LEAF_SIGNATURE</code> dans les logs (voir plus bas).
Solution : installez <code>mcp-remote</code> une bonne fois pour toutes (<code>npm install -g
mcp-remote</code>) puis redémarrez Claude Desktop <code>npx</code> utilisera alors le binaire déjà
installé sans repasser par le registre à chaque fois.
</p>
<p style={{ margin: '0 0 4px', fontSize: 'var(--fs-sm)', fontWeight: 600 }}> trouver les logs</p>
<p style={{ margin: '0 0 14px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
<code>logs\mcp.log</code> trace les échanges entre Claude Desktop et le process local (tous serveurs
confondus) ; <code>logs\mcp-server-{serverKey}.log</code> contient la sortie détaillée de ce serveur
précis, uniquement si <code>--debug</code> est activé ci-dessus (case à cocher, étape 3). Le bouton
« Afficher les journaux » de Claude Desktop peut ne pas s'ouvrir sous Windows (bug connu) — allez
chercher directement dans le dossier <code>logs</code>, à l'un des deux emplacements mentionnés à
l'étape 3 (essayez l'autre si l'un des deux est vide ou ne se met pas à jour).
</p>
{!isLocal && (
<>
<p style={{ margin: '0 0 4px', fontSize: 'var(--fs-sm)', fontWeight: 600 }}>
Connexion refusée avec une erreur de certificat, alors que le serveur répond bien
</p>
<p style={{ margin: '0 0 14px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
Symptôme différent du précédent : la config est correcte, mais <code>mcp-remote</code> échoue à
joindre l'URL de production elle-même (pas le registre npm cette fois) avec une erreur
<code> UNABLE_TO_VERIFY_LEAF_SIGNATURE</code> dans les logs. Même cause de fond un antivirus ou
un proxy avec inspection HTTPS (Avast, Kaspersky, ESET) re-signe le trafic avec son propre
certificat, que Node.js ne reconnaît pas (contrairement à votre navigateur, qui lui fait
confiance via le magasin Windows). Solution : cochez la case <code>NODE_OPTIONS=--use-system-ca</code>
ci-dessus (étape 3) Node utilisera alors le magasin de certificats Windows plutôt que le sien.
Nécessite Node.js 22.16 ou plus récent (vérifiable avec <code>node -v</code>) ; sur une version
plus ancienne, il faut exporter le certificat racine de l'antivirus (<code>certmgr.msc</code> →
Autorités de certification racines de confiance → Exporter en Base-64 X.509) et le référencer via
<code> NODE_EXTRA_CA_CERTS</code> à la place. Évitez de désactiver complètement la vérification
TLS (<code>NODE_TLS_REJECT_UNAUTHORIZED=0</code>) : cette connexion sort sur internet avec votre
clé API et vos données, contrairement au développement local en <code>localhost</code>.
</p>
</>
)}
{isLocal && (
<>
<p style={{ margin: '0 0 4px', fontSize: 'var(--fs-sm)', fontWeight: 600 }}>Vérifier côté serveur</p>
<p style={{ margin: 0, fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
La console du serveur (le terminal où tourne <code>npm run dev</code>) affiche désormais une ligne
par requête reçue — session, outil appelé, statut, durée. Si rien n'y apparaît alors qu'un appel a
été fait depuis Claude Desktop, la requête n'arrive jamais jusqu'ici : le problème est côté
<code>npx</code>/<code>mcp-remote</code> (voir ci-dessus), pas dans <code>server.js</code>.
</p>
</>
)}
</div>
);
}
/* ── Page principale ─────────────────────────────────────────── */
export default function MonCompte() {
const { search } = useLocation();
@@ -794,6 +1462,8 @@ export default function MonCompte() {
const SECTIONS = [
{ id: 'profil', label: 'Mon compte', icon: <IconUser /> },
{ id: 'securite', label: 'Sécurité', icon: <IconLock /> },
{ id: 'api-keys', label: 'Clés API', icon: <IconKey /> },
{ id: 'mcp', label: 'Serveur MCP', icon: <IconServer /> },
];
return (
@@ -818,6 +1488,8 @@ export default function MonCompte() {
<div className="account-content-narrow">
{section === 'profil' && <><AccountForm /><DeleteAccountSection /></>}
{section === 'securite' && <><SecurityForm /><TwoFASection user={user} /><TrustedDevicesSection /></>}
{section === 'api-keys' && <ApiKeysSection />}
{section === 'mcp' && <McpServerSection goToApiKeys={() => setSection('api-keys')} />}
</div>
</div>
+99 -2
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';
@@ -597,6 +599,49 @@ 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 dû 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;
@@ -668,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">
@@ -736,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 ── */}
@@ -1422,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>
);
+1 -1
View File
@@ -1286,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>
+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>
+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 */}
@@ -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()}>
+629 -130
View File
@@ -1,7 +1,8 @@
import { useState, useEffect, useRef } from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import * as XLSX from 'xlsx';
import { api } from '../../api.js';
import { fmtDate } from '../../utils/format.js';
import { fmtDate, fmtEUR } from '../../utils/format.js';
import { useInvestisseur } from '../../context/InvestisseurContext.jsx';
import ResultBanner from '../../components/ResultBanner.jsx';
@@ -13,6 +14,79 @@ function dlBlob(content, filename, type) {
URL.revokeObjectURL(url);
}
/** 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 templateToCSV(headers, rows) {
const BOM = ''; const sep = ';';
const q = v => `"${String(v ?? '').replace(/"/g, '""')}"`;
const data = rows.map(r => headers.map(h => r[h] ?? ''));
return BOM + [headers, ...data].map(r => r.map(q).join(sep)).join('\r\n');
}
function templateToXLS(headers, rows) {
const data = rows.map(r => {
const obj = {};
headers.forEach(h => { obj[h] = r[h] ?? ''; });
return obj;
});
const ws = XLSX.utils.json_to_sheet(data, { header: headers });
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Import');
return XLSX.write(wb, { type: 'array', bookType: 'xlsx' });
}
function templateToJSON(rows) {
return JSON.stringify(rows, null, 2);
}
/** Popup de choix de format, déclenché par un bouton texte (pas une icône) */
function TemplateDropdown({ disabled, busy, onCSV, onXLS, onJSON }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const h = e => { if (!ref.current?.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', h);
return () => document.removeEventListener('mousedown', h);
}, [open]);
const choose = fn => { setOpen(false); fn(); };
return (
<div ref={ref} style={{ position: 'relative', display: 'inline-block' }}>
<button type="button" disabled={disabled || busy} onClick={() => setOpen(o => !o)}>
{busy ? '…' : 'Générer un fichier pour compléter votre importation'}
</button>
{open && (
<div className="export-dropdown" role="menu">
<button role="menuitem" onClick={() => choose(onCSV)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
<line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/>
</svg>
<span><strong>Format CSV</strong><small>Compatible Excel, LibreOffice</small></span>
</button>
<button role="menuitem" onClick={() => choose(onXLS)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M9 13l2 2 4-4"/>
</svg>
<span><strong>Format Excel</strong><small>Fichier .xlsx Microsoft Excel</small></span>
</button>
<button role="menuitem" onClick={() => choose(onJSON)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
<path d="M8 13h1.5a1 1 0 0 1 1 1v1a1 1 0 0 0 1 1 1 1 0 0 0-1 1v1a1 1 0 0 1-1 1H8"/>
<path d="M16 13h-1.5a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1 1 1v1a1 1 0 0 0 1 1H16"/>
</svg>
<span><strong>Format JSON</strong><small>Réimportable, structuré</small></span>
</button>
</div>
)}
</div>
);
}
const countryLabel = code => COUNTRIES.find(c => c.code === code)?.name ?? code ?? '—';
const FISCALITE_LABELS = {
flat_tax: 'Flat Tax',
@@ -105,7 +179,7 @@ const MODULES = {
remboursements: {
label: 'Remboursements',
required: ['investissement_id', 'date_remb'],
optional: ['capital', 'interets_bruts', 'prelev_sociaux', 'prelev_forfaitaire', 'net_recu', 'statut'],
optional: ['capital', 'cashback', 'interets_bruts', 'prelev_sociaux', 'prelev_forfaitaire', 'statut'],
needsInvestisseur: true,
},
plateformes: {
@@ -245,31 +319,119 @@ const FIELD_HINTS_OVERRIDE = {
taux_pfu: {
prelev_sociaux: 'taux de prélèvements sociaux en % (nombre, ex: 17.2)',
},
remboursements: {
capital: 'part de capital remboursée, en euros (nombre). Voir la section RÈGLE CAPITAL vs INTÉRÊTS ci-dessous : '
+ 'ne déduis JAMAIS ce champ par simple lecture d\'une colonne "capital" si le fichier n\'en a pas — reconstitue-le '
+ 'si nécessaire à partir du montant total de la ligne et des prélèvements associés.',
interets_bruts: 'intérêts bruts perçus, en euros (nombre, avant prélèvements). ⚠️ Voir la section RÈGLE CAPITAL vs '
+ 'INTÉRÊTS ci-dessous avant de remplir ce champ : ne recopie JAMAIS aveuglément le montant total d\'une ligne de '
+ 'remboursement dans ce champ sans vérifier sa cohérence avec les prélèvements sociaux/IR associés.',
cashback: 'montant de cashback/prime/bonus perçu, en euros (nombre) — voir la section RÈGLE CAPITAL vs INTÉRÊTS '
+ 'ci-dessous : une ligne de cashback/bonus/récompense se reconnaît à son libellé (pas à sa position dans le '
+ 'fichier) et n\'est jamais soumise à prélèvement.',
},
investissements: {
nom_projet: 'trouve le nom du projet naturellement dans le fichier, puis FORMATE-le ainsi : '
+ '"NOM DE L\'ÉMETTEUR EN MAJUSCULES - nom du projet trouvé" (ex: "SCI DUPONT - Résidence Les Tilleuls"). '
+ 'Anti-doublon RENFORCÉ, à appliquer en 2 temps : '
+ '1) Avant de préfixer, cherche dans TOUT le nom du projet trouvé (pas seulement en tête) une occurrence du nom '
+ 'de l\'émetteur — y compris partielle ou abrégée (ex. "HMC" est une abréviation de "HMC GROUP", "MULTIPRINT" est '
+ 'le premier mot de "MULTIPRINT SAM") ou une variante proche (casse différente, "&" à la place de "ET", '
+ 'singulier/pluriel, ponctuation différente, ex. "QUALI PARTS & SERVICE" proche de "QUALI PARTS ET SERVICES"). '
+ '2) Si une telle occurrence existe n\'importe où dans le nom du projet trouvé, ne rajoute PAS le préfixe et '
+ 'SUPPRIME cette occurrence redondante du nom du projet (garde uniquement la partie descriptive restante, ex. '
+ '"HMC GROUP - HMC - Commande CSH25" devient "HMC GROUP - Commande CSH25", '
+ '"MULTIPRINT SAM - MULTIPRINT - ACM 06/2025" devient "MULTIPRINT SAM - ACM 06/2025", '
+ '"QUALI PARTS ET SERVICES - QUALI PARTS & SERVICE - Fact 981124" devient "QUALI PARTS ET SERVICES - Fact 981124"). '
+ 'Ne garde JAMAIS deux mentions (même partielles ou orthographiées différemment) du même émetteur dans le résultat final. '
+ 'Correction typographique : si le nom du projet contient des sigles ou des noms composés de segments courts '
+ 'séparés par des virgules (ex. "A,I,E,", "B,L,M,F,", "TWELVE,COM"), il s\'agit presque toujours d\'un point '
+ 'mal interprété (OCR) — remplace ces virgules par des points ("A.I.E.", "B.L.M.F.", "TWELVE.COM"). '
+ 'Si l\'émetteur est introuvable, laisse simplement le nom du projet tel quel, sans préfixe.',
},
};
function buildFieldsList(mod) {
function buildFieldsList(mod, scopePlateforme) {
const def = MODULES[mod];
if (!def) return '';
const line = (key, required) => {
if (key === 'plateforme_id' && scopePlateforme) {
return `- ${key} (non nécessaire) : ne l'inclus pas — cet import concerne exclusivement la plateforme `
+ `"${scopePlateforme.nom}" (voir section PLATEFORME ci-dessous).`;
}
const hint = FIELD_HINTS_OVERRIDE[mod]?.[key] ?? FIELD_HINTS[key] ?? '';
return `- ${key} (${required ? 'obligatoire' : 'optionnel'})${hint ? ` : ${hint}` : ''}`;
};
const lines = [];
for (const key of def.required) {
const hint = FIELD_HINTS_OVERRIDE[mod]?.[key] ?? FIELD_HINTS[key] ?? '';
lines.push(`- ${key} (obligatoire)${hint ? ` : ${hint}` : ''}`);
}
for (const key of def.optional) {
const hint = FIELD_HINTS_OVERRIDE[mod]?.[key] ?? FIELD_HINTS[key] ?? '';
lines.push(`- ${key} (optionnel)${hint ? ` : ${hint}` : ''}`);
}
for (const key of def.required) lines.push(line(key, true));
for (const key of def.optional) lines.push(line(key, false));
return lines.join('\n');
}
function buildReferenceSection(mod, plats, investissements) {
if ((mod === 'depots_retraits' || mod === 'investissements') && plats.length > 0) {
const names = plats.map(p => p.nom).join(' | ');
return `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nPLATEFORMES EXISTANTES (pour plateforme_id)\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nUtilise EXACTEMENT l'un de ces noms si la plateforme correspond à l'une d'elles :\n${names}\n\n⚠️ Si le fichier ne permet pas d'identifier avec certitude de quelle plateforme il provient (nom absent du fichier, logo/en-tête ambigu, etc.), NE DEVINE PAS et NE GÉNÈRE PAS le JSON tout de suite : pose-moi d'abord la question « De quelle plateforme provient ce fichier ? » en ne me proposant QUE ces réponses possibles :\n${names}\nAttends ma réponse, puis utilise le nom choisi comme valeur de plateforme_id pour TOUTES les lignes générées.\n\n`;
function buildReferenceSection(mod, plats, investissements, scopePlateforme) {
if (mod === 'depots_retraits' || mod === 'investissements') {
if (scopePlateforme) {
return `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nPLATEFORME\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nCet import concerne EXCLUSIVEMENT la plateforme "${scopePlateforme.nom}". `
+ `N'inclus PAS de champ plateforme_id dans le JSON généré (il est déjà connu et sera appliqué automatiquement à chaque ligne).\n\n`;
}
if (plats.length === 1) {
const only = plats[0].nom;
return `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nPLATEFORME (une seule existante)\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nUne seule plateforme est enregistrée à ce jour : "${only}". Il n'y a donc AUCUNE ambiguïté possible : utilise "${only}" comme valeur de plateforme_id pour TOUTES les lignes générées, sans me poser de question — même si le fichier ne mentionne aucun nom de plateforme, aucun logo ni en-tête identifiable.\nNe me pose une question QUE dans le cas précis suivant : le fichier mentionne EXPLICITEMENT un nom de plateforme différent de "${only}" (texte, logo ou en-tête clairement identifiable). Dans ce seul cas, NE GÉNÈRE PAS le JSON tout de suite et indique-moi le nom trouvé en me demandant s'il s'agit d'une nouvelle plateforme à créer ou d'une erreur de fichier.\n\n`;
}
if (plats.length > 1) {
const names = plats.map(p => p.nom).join(' | ');
return `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nPLATEFORMES EXISTANTES (pour plateforme_id)\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nUtilise EXACTEMENT l'un de ces noms si la plateforme correspond à l'une d'elles :\n${names}\n\n⚠️ Si le fichier ne permet pas d'identifier avec certitude de quelle plateforme il provient (nom absent du fichier, logo/en-tête ambigu, etc.), NE DEVINE PAS et NE GÉNÈRE PAS le JSON tout de suite : pose-moi d'abord la question « De quelle plateforme provient ce fichier ? » en ne me proposant QUE ces réponses possibles :\n${names}\nAttends ma réponse, puis utilise le nom choisi comme valeur de plateforme_id pour TOUTES les lignes générées.\n\n`;
}
}
if (mod === 'remboursements' && investissements.length > 0) {
const names = investissements.map(i => i.nom_projet).join(' | ');
return `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nINVESTISSEMENTS EXISTANTS (pour investissement_id)\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nUtilise EXACTEMENT l'un de ces noms de projet si le remboursement correspond à l'un d'eux :\n${names}\n\n⚠️ Si le fichier ne permet pas d'identifier avec certitude à quel investissement une ligne correspond, NE DEVINE PAS et NE GÉNÈRE PAS le JSON tout de suite : pose-moi d'abord la question « À quel investissement correspond ce fichier ? » en ne me proposant QUE ces réponses possibles :\n${names}\nAttends ma réponse, puis utilise le nom choisi comme valeur d'investissement_id pour TOUTES les lignes générées.\n\n`;
if (mod === 'remboursements') {
let out = `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nRÈGLE CAPITAL vs INTÉRÊTS\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`
+ `De nombreux exports de plateformes affichent UNE SEULE ligne "Remboursement" / "Remboursement mensualité" par échéance, `
+ `dont le montant peut mélanger CAPITAL et INTÉRÊTS (prêts amortissables), accompagnée de une ou deux lignes de prélèvement `
+ `fiscal séparées juste avant/après dans le fichier (souvent nommées "CSG/CRDS", "Prélèvement IR/PFU", "Prélèvements sociaux", etc.). `
+ `Ne recopie JAMAIS le montant total d'une ligne de remboursement dans interets_bruts sans avoir vérifié ceci :\n`
+ `1. Le taux global de prélèvement français sur des intérêts est d'environ 30 % (≈17,2 % de prélèvements sociaux + ≈12,8 % de prélèvement forfaitaire/IR).\n`
+ `2. Calcule le ratio (prélèvements sociaux + prélèvement forfaitaire trouvés) ÷ (montant total de la ligne de remboursement). `
+ `Si ce ratio est PROCHE de 30 % (tolérance large, ex. 20 à 35 %), la ligne est un remboursement d'INTÉRÊTS PUR : interets_bruts = montant total de la ligne, n'inclus PAS de champ capital.\n`
+ `3. Si ce ratio est NETTEMENT inférieur (quelques % voire < 1 %), la ligne mélange capital et intérêts : reconstitue le VRAI montant `
+ `d'intérêts bruts à partir des prélèvements eux-mêmes — interets_bruts ≈ prélèvement_sociaux ÷ 0,172, à recouper avec prélèvement_forfaitaire ÷ 0,128 `
+ `(les deux estimations doivent être proches ; en cas d'écart, privilégie leur moyenne) — puis capital = montant total de la ligne interets_bruts ainsi recalculé. `
+ `N'utilise JAMAIS le montant total brut de la ligne comme interets_bruts dans ce cas.\n`
+ `4. IDENTIFICATION DU CASHBACK/BONUS — avant d'appliquer la règle 5 ci-dessous, vérifie si le LIBELLÉ de la ligne `
+ `indique une prime, un bonus, un cashback, une récompense, un code cadeau ou une rémunération de parrainage/filleul `
+ `(mots-clés typiques : "cashback", "bonus", "prime", "récompense", "code cadeau", "parrainage", "filleul", `
+ `"rémunération" hors mensualité). Ce type de versement se reconnaît à son LIBELLÉ, jamais à sa position dans le `
+ `fichier ni à l'absence de ligne de prélèvement adjacente (une ligne de remboursement de capital pur n'a elle non `
+ `plus aucun prélèvement adjacent — ne confonds pas les deux cas). Si c'est le cas : renseigne uniquement le champ `
+ `cashback = montant total de la ligne (capital = 0, interets_bruts = 0, prelev_sociaux = 0, prelev_forfaitaire = 0), `
+ `ce type de versement n'étant jamais soumis à prélèvement. Si ce versement est lié à un projet identifiable de la `
+ `liste des investissements ci-dessous, utilise cet investissement_id ; si le libellé ne mentionne AUCUN projet `
+ `identifiable (ex. bonus de parrainage global, non lié à un prêt précis), N'INCLUS PAS cette ligne dans le JSON — `
+ `le module d'import ne permet pas d'enregistrer un versement sans investissement associé.\n`
+ `5. Si AUCUNE ligne de prélèvement n'est associée à la ligne de remboursement (aucun CSG/CRDS ni prélèvement IR/PFU adjacent) `
+ `ET que ce n'est PAS une ligne de cashback/bonus (voir règle 4 ci-dessus), il s'agit d'un remboursement `
+ `de capital PUR (souvent l'échéance finale d'un prêt in fine/différé) : capital = montant total de la ligne.\n`
+ `6. ⚠️ RÈGLE CRITIQUE — présence systématique des champs : sur CHAQUE ligne du JSON généré, inclus TOUJOURS explicitement les 5 champs `
+ `capital, cashback, interets_bruts, prelev_sociaux, prelev_forfaitaire — même quand leur valeur est 0. N'omets JAMAIS un champ sous prétexte `
+ `qu'il vaut 0 sur cette ligne précise (ex. une ligne de remboursement de capital pur doit quand même inclure "interets_bruts": 0, "prelev_sociaux": 0, `
+ `"prelev_forfaitaire": 0, et pas seulement "capital"). Cette règle est impérative : l'application détecte les colonnes disponibles à partir des champs `
+ `présents sur la PREMIÈRE ligne du JSON uniquement — un champ absent de la première ligne ne sera JAMAIS pris en compte à l'import, même s'il apparaît `
+ `rempli sur une ligne suivante (ex. le remboursement final de capital serait alors importé à 0 €, silencieusement). `
+ `N'inclus PAS de champ "net_recu" : il n'est pas utilisé par l'import, qui recalcule lui-même le montant net à partir de capital/cashback/interets_bruts/prélèvements.\n\n`;
if (investissements.length > 0) {
const names = investissements.map(i => i.nom_projet).join(' | ');
const scopeNote = scopePlateforme ? ` de la plateforme "${scopePlateforme.nom}"` : '';
out += `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nINVESTISSEMENTS EXISTANTS${scopeNote} (pour investissement_id)\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nPour CHAQUE ligne, recherche dans la liste ci-dessous le nom de projet dont la correspondance est la PLUS PROCHE `
+ `(émetteur, référence de commande/facture, mots-clés communs — même partiels ou approximatifs) et utilise EXACTEMENT ce nom comme `
+ `valeur d'investissement_id. Une correspondance approximative suffit : ne cherche pas une identité parfaite, une similarité claire et `
+ `raisonnable est acceptable. Ne me pose AUCUNE question ligne par ligne — traite tout le fichier directement.\n${names}\n\n`
+ `⚠️ Ce n'est que si, après analyse de l'ensemble du fichier, une ou plusieurs lignes ne présentent VRAIMENT aucune ressemblance avec `
+ `l'un de ces noms (aucun mot commun, aucune référence reconnaissable) que tu peux me poser, à la toute fin, UNE SEULE question groupée `
+ `pour l'ensemble de ces lignes (pas une par ligne), du type « Je n'ai pas trouvé de correspondance fiable pour les lignes suivantes : `
+ `[...]. À quel investissement correspondent-elles ? » en ne me proposant QUE ces réponses possibles :\n${names}\n`
+ `Sinon, génère directement le JSON complet pour toutes les lignes sans attendre de réponse.\n\n`;
}
return out;
}
return '';
}
@@ -337,9 +499,18 @@ function IaImportPasteBlock({ moduleLabel, iaJson, setIaJson, iaErr, setIaErr, o
resize: 'vertical', background: 'var(--surface-2)', border: '1px solid var(--border)',
borderRadius: 6, padding: 10, color: 'var(--text)', boxSizing: 'border-box' }} />
{iaErr && <div className="error" style={{ marginTop: 8 }}>{iaErr}</div>}
<button className="primary" onClick={onAnalyze} disabled={!iaJson.trim() || busy} style={{ marginTop: 10 }}>
{busy ? '' : 'Analyser les données '}
</button>
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
<button className="primary" onClick={onAnalyze} disabled={!iaJson.trim() || busy}>
{busy ? '' : 'Analyser les données '}
</button>
<button
type="button"
onClick={() => { setIaJson(''); setIaErr(null); }}
disabled={!iaJson || busy}
>
Vider
</button>
</div>
</div>
)}
</div>
@@ -347,9 +518,8 @@ function IaImportPasteBlock({ moduleLabel, iaJson, setIaJson, iaErr, setIaErr, o
}
/** Bloc collapsible : prompt IA dynamique (champs du module + plateformes/investissements existants) */
function IaImportPromptBlock({ module, moduleLabel, plats, investissements }) {
function IaImportPromptBlock({ module, moduleLabel, plats, investissements, scopePlateforme, open, setOpen }) {
const LS_KEY = 'cl_import_ia_prompt_template';
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState(false);
const [prompt, setPrompt] = useState(() => localStorage.getItem(LS_KEY) || DEFAULT_IA_IMPORT_PROMPT);
const [draft, setDraft] = useState('');
@@ -357,8 +527,8 @@ function IaImportPromptBlock({ module, moduleLabel, plats, investissements }) {
const resolvedPrompt = () => prompt
.replace('{{MODULE_LABEL}}', moduleLabel)
.replace('{{FIELDS_LIST}}', buildFieldsList(module))
.replace('{{REFERENCE_SECTION}}', buildReferenceSection(module, plats, investissements));
.replace('{{FIELDS_LIST}}', buildFieldsList(module, scopePlateforme))
.replace('{{REFERENCE_SECTION}}', buildReferenceSection(module, plats, investissements, scopePlateforme));
const startEdit = () => { setDraft(prompt); setEditing(true); };
const saveEdit = () => { setPrompt(draft); localStorage.setItem(LS_KEY, draft); setEditing(false); };
@@ -425,6 +595,173 @@ function IaImportPromptBlock({ module, moduleLabel, plats, investissements }) {
);
}
/* ══════════════════════════════════════════════════════════════
Modale de doublons — présente, ligne par ligne, la correspondance
trouvée en base en regard de la ligne importée, pour accepter ou
refuser chaque import en doublon (tous modules confondus).
══════════════════════════════════════════════════════════════ */
/** Libellés humains des champs pouvant apparaître dans incoming/existing */
const DUP_FIELD_LABELS = {
plateforme: 'Plateforme', date_operation: 'Date', type: 'Type', montant: 'Montant',
libelle: 'Libellé', reference: 'Référence',
nom_projet: 'Projet', date_souscription: 'Date souscription', montant_investi: 'Montant investi',
taux_interet: "Taux d'intérêt", duree_mois: 'Durée (mois)', statut: 'Statut',
investissement: 'Investissement', date_remb: 'Date remboursement', capital: 'Capital',
cashback: 'Cashback', interets_bruts: 'Intérêts bruts', prelev_sociaux: 'Prélèv. sociaux',
prelev_forfaitaire: 'Prélèv. forfaitaire',
nom: 'Nom', url: 'URL', notes: 'Notes',
annee: 'Année', pfu_total: 'PFU total (%)', impot_revenu: 'Impôt revenu (%)',
};
/** Formatage léger d'une valeur selon le nom du champ (date / montant / brut) */
function fmtDupValue(field, value) {
if (value === null || value === undefined || value === '') return '—';
if (field.startsWith('date_') || field === 'date_operation') return fmtDate(value);
if (['montant', 'montant_investi', 'capital', 'cashback', 'interets_bruts', 'prelev_sociaux', 'prelev_forfaitaire'].includes(field)) {
return fmtEUR(Number(value));
}
if (['taux_interet', 'pfu_total', 'impot_revenu'].includes(field)) return `${value} %`;
return String(value);
}
/** true si les deux valeurs diffèrent (comparaison souple, insensible au type) */
function dupValuesDiffer(a, b) {
if (a === null || a === undefined) a = '';
if (b === null || b === undefined) b = '';
return String(a) !== String(b);
}
/** Une ligne "doublon" repliable : case à cocher en tête de ligne (comme titre), détail masqué par défaut */
function DuplicateRow({ d, decision, onDecisionChange, busy }) {
const [detailOpen, setDetailOpen] = useState(false);
const fields = [...new Set([...Object.keys(d.existing || {}), ...Object.keys(d.incoming || {})])];
return (
<div style={{ border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden' }}>
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 12px', background: 'var(--surface-2, var(--bg))',
}}>
<label style={{
display: 'flex', alignItems: 'center', gap: 8, flex: 1, minWidth: 0, cursor: 'pointer',
// Neutralise le style global des <label> (légendes de champ en majuscules) :
// ici c'est le titre cliquable d'une ligne à cocher, pas une légende de formulaire.
textTransform: 'none', letterSpacing: 'normal', margin: 0, fontWeight: 400,
fontSize: 13, color: 'var(--text)',
}}>
<input
type="checkbox"
checked={decision === 'accept'}
disabled={busy}
// Le style global `input { width: 100%; padding: 7px 10px; }` (prévu pour les champs
// texte) s'applique aussi aux cases à cocher — on le neutralise ici explicitement
// (même correctif que .cat-select-item / .plat-multiselect-item dans ce fichier CSS).
style={{ width: 14, height: 14, padding: 0, flexShrink: 0, accentColor: 'var(--primary)' }}
onChange={e => onDecisionChange(e.target.checked ? 'accept' : 'skip')}
/>
<span style={{ fontWeight: 600 }}>
Doublon de données repéré en ligne {d.row} avec {d.matchedRow ? `celles de la ligne ${d.matchedRow}` : 'un enregistrement déjà en base'}
</span>
</label>
<button
type="button"
onClick={() => setDetailOpen(o => !o)}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, display: 'flex', color: 'var(--text-muted)' }}
title={detailOpen ? 'Masquer la comparaison' : 'Voir la comparaison'}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
style={{ transform: detailOpen ? 'rotate(180deg)' : 'none', transition: 'transform .2s' }}>
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
</div>
{detailOpen && (
<div style={{ padding: '10px 12px', borderTop: '1px solid var(--border)' }}>
{d.matchedRow && (
<div className="text-muted" style={{ fontSize: 11, marginBottom: 6 }}>
Doublon avec la ligne {d.matchedRow} du même fichier (pas encore en base)
</div>
)}
<table style={{ margin: 0, fontSize: 12 }}>
<thead>
<tr><th>Champ</th><th>{d.matchedRow ? `Ligne ${d.matchedRow} du fichier` : 'Existant en base'}</th><th>Ligne importée</th></tr>
</thead>
<tbody>
{fields.map(f => {
const diff = dupValuesDiffer(d.existing?.[f], d.incoming?.[f]);
return (
<tr key={f}>
<td className="text-muted">{DUP_FIELD_LABELS[f] || f}</td>
<td>{fmtDupValue(f, d.existing?.[f])}</td>
<td style={diff ? { color: 'var(--warning)', fontWeight: 600 } : undefined}>
{fmtDupValue(f, d.incoming?.[f])}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
function DuplicatesModal({ open, module, moduleLabel, duplicates, decisions, setDecisions, onConfirm, onCancel, busy }) {
if (!open) return null;
const isTauxPfu = module === 'taux_pfu';
const setAll = (decision) => {
const next = {};
for (const d of duplicates) next[d.row] = decision;
setDecisions(next);
};
return (
<div className="modal-overlay" onClick={onCancel}>
<div className="modal" style={{ maxWidth: 720, maxHeight: '85vh', display: 'flex', flexDirection: 'column' }} onClick={e => e.stopPropagation()}>
<div className="modal-header" style={{ borderBottom: '1px solid var(--border)', paddingBottom: 12, marginBottom: 12 }}>
<h3 style={{ margin: 0 }}>⚠ {duplicates.length} doublon{duplicates.length > 1 ? 's' : ''} détecté{duplicates.length > 1 ? 's' : ''} — {moduleLabel}</h3>
</div>
<p className="text-muted" style={{ margin: '0 0 12px', fontSize: 13, lineHeight: 1.5 }}>
Ces lignes du fichier correspondent à des enregistrements déjà présents en base.
{isTauxPfu
? ' Par défaut, le taux existant sera mis à jour avec les nouvelles valeurs (comme aujourd\'hui) — décochez pour le conserver tel quel.'
: ' Par défaut, elles sont ignorées — cochez pour les importer quand même.'}
{' '}Les autres lignes du fichier (non listées ici) seront importées normalement.
{' '}Cliquez sur la flèche pour voir le détail de la comparaison.
</p>
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<button type="button" style={{ fontSize: 12, padding: '4px 10px' }} onClick={() => setAll('accept')} disabled={busy}>
Cocher tous les doublons
</button>
<button type="button" style={{ fontSize: 12, padding: '4px 10px' }} onClick={() => setAll('skip')} disabled={busy}>
Décocher tous les doublons
</button>
</div>
<div style={{ overflowY: 'auto', flex: 1, display: 'flex', flexDirection: 'column', gap: 8 }}>
{duplicates.map(d => (
<DuplicateRow
key={d.row}
d={d}
decision={decisions[d.row] ?? (isTauxPfu ? 'accept' : 'skip')}
onDecisionChange={(val) => setDecisions({ ...decisions, [d.row]: val })}
busy={busy}
/>
))}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 14, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
<button onClick={onCancel} disabled={busy}>Annuler l'import</button>
<button className="primary" onClick={onConfirm} disabled={busy}>
{busy ? 'Import en cours…' : 'Confirmer et importer'}
</button>
</div>
</div>
</div>
);
}
/* ── Imports — composant dossier ─────────────────────────────── */
function DossierImport({
activeId, navigate,
@@ -474,7 +811,7 @@ function DossierImport({
return (
<div className="card">
<h3 style={{ margin: '0 0 4px' }}>Import Dossier investissement</h3>
<h3 style={{ margin: '0 0 4px' }}>Étape 2 - Précisez le fichier source de votre dossier d'investissement</h3>
<p className="text-muted" style={{ fontSize: 'var(--fs-sm)', marginBottom: 12 }}>
Restaure ou migre un dossier complet (investissement + remboursements + historique) depuis un fichier
<code style={{ margin: '0 4px' }}>.json</code> exporté par cette application.
@@ -552,6 +889,11 @@ export default function ImportsSection() {
const m = searchParams.get('module');
return MODULES[m] ? m : 'depots_retraits';
});
// null par défaut : seuls "Contexte" et "Historique" sont visibles au chargement.
// Si on arrive via un lien direct (?module=...), on ouvre directement "Fichier source".
const [activeMode, setActiveMode] = useState(() => searchParams.get('module') ? 'fichier' : null);
// '' = import multi-plateforme (défaut, comportement inchangé) ; sinon id de la plateforme unique visée.
const [scopePlateformeId, setScopePlateformeId] = useState('');
const [file, setFile] = useState(null);
const [preview, setPreview] = useState(null);
const [mapping, setMapping] = useState({});
@@ -564,6 +906,7 @@ export default function ImportsSection() {
const [err, setErr] = useState(null);
const [anomalies, setAnomalies] = useState([]);
const [fixingPlatId, setFixingPlatId] = useState(null);
const [templateBusy, setTemplateBusy] = useState(false);
const [dossierFile, setDossierFile] = useState(null);
const [dossierPreview, setDossierPreview] = useState(null);
@@ -577,6 +920,16 @@ export default function ImportsSection() {
api.get('/plateformes').then(setPlats).catch(() => {});
}, []);
// Ré-affiche le résultat du dernier import juste après le rechargement automatique de la
// page qui suit un import réussi (voir runApply) — sessionStorage survit au reload.
useEffect(() => {
const msg = sessionStorage.getItem('cl_import_last_result');
if (msg) {
setResult({ ok: true, msg });
sessionStorage.removeItem('cl_import_last_result');
}
}, []);
// Nettoie le paramètre ?module= une fois consommé (évite de le reproposer au refresh)
useEffect(() => {
if (!searchParams.get('module')) return;
@@ -595,6 +948,18 @@ export default function ImportsSection() {
const allTargets = def ? [...def.required, ...def.optional] : [];
const missingInv = def?.needsInvestisseur && !activeId;
// Un import peut viser une plateforme unique plutôt que multi-plateforme (défaut) —
// pertinent uniquement pour les modules dont les lignes sont rattachées à une plateforme.
const platformScopable = module === 'depots_retraits' || module === 'investissements' || module === 'remboursements';
const scopePlateforme = platformScopable
? plats.find(p => String(p.id) === String(scopePlateformeId)) || null
: null;
// Pour Remboursements, restreint la liste de référence (et les résolutions) aux
// investissements de la plateforme ciblée quand une plateforme unique est choisie.
const scopedInvestissements = (module === 'remboursements' && scopePlateforme)
? investissements.filter(i => String(i.plateforme_id) === String(scopePlateforme.id))
: investissements;
/**
* Analyse un fichier (upload classique OU JSON généré par IA) pour le module ciblé :
* upload + auto-mapping des colonnes + pré-remplissage des valeurs par défaut résolues.
@@ -623,6 +988,9 @@ export default function ImportsSection() {
}
if (col) { auto[t] = col; usedHeaders.add(col); }
}
// Plateforme unique choisie à l'étape 1 : elle est déjà connue, on ignore toute colonne
// "plateforme" du fichier pour ne jamais la contredire (voir aussi buildReferenceSection/buildFieldsList).
if (scopePlateformeId && targets.includes('plateforme_id')) delete auto.plateforme_id;
setMapping(auto);
// Pré-remplit la valeur par défaut quand toutes les lignes de l'échantillon
@@ -630,7 +998,7 @@ export default function ImportsSection() {
const autoDefaults = {};
for (const t of Object.keys(auto)) {
if (!NAME_RESOLVABLE_FIELDS.has(t)) continue;
const refList = t === 'plateforme_id' ? plats : investissements;
const refList = t === 'plateforme_id' ? plats : scopedInvestissements;
const nameKey = t === 'plateforme_id' ? 'nom' : 'nom_projet';
const rawValues = [...new Set(
r.sampleRows.map(row => row[auto[t]]).filter(v => v !== null && v !== undefined && String(v).trim() !== '')
@@ -639,6 +1007,7 @@ export default function ImportsSection() {
const match = resolvePreviewMatch(rawValues[0], refList, nameKey);
if (match) autoDefaults[t] = String(match.id);
}
if (scopePlateformeId && targets.includes('plateforme_id')) autoDefaults.plateforme_id = scopePlateformeId;
if (Object.keys(autoDefaults).length > 0) setDefaults(d => ({ ...d, ...autoDefaults }));
} catch (e) { setErr(e.message); }
finally { setBusy(false); }
@@ -652,6 +1021,7 @@ export default function ImportsSection() {
/** Parse le JSON collé (issu d'un assistant IA) et l'envoie dans le même pipeline d'analyse. */
const [iaJson, setIaJson] = useState('');
const [iaErr, setIaErr] = useState(null);
const [iaPromptOpen, setIaPromptOpen] = useState(false);
const analyzeIaJson = async () => {
setIaErr(null);
let arr;
@@ -661,32 +1031,70 @@ export default function ImportsSection() {
} catch (e) { setIaErr('JSON invalide : ' + e.message); return; }
if (!Array.isArray(arr)) { setIaErr("Le JSON doit être un tableau d'objets (une entrée par ligne)."); return; }
if (arr.length === 0) { setIaErr('Le tableau est vide.'); return; }
setIaPromptOpen(false); // replie le bloc "Prompt IA" une fois l'analyse lancée
const blob = new Blob([JSON.stringify(arr)], { type: 'application/json' });
const iaFile = new File([blob], 'import-ia.json', { type: 'application/json' });
await runPreview(iaFile, module);
};
const apply = async () => {
// Modale de doublons — se déclenche automatiquement au clic sur "Importer" (voir apply()).
const [dupModalOpen, setDupModalOpen] = useState(false);
const [dupList, setDupList] = useState([]);
const [dupDecisions, setDupDecisions] = useState({});
const [dupBusy, setDupBusy] = useState(false);
const runApply = async (duplicateDecisions = {}) => {
setBusy(true); setErr(null);
try {
const r = await api.post('/imports/apply', {
tempId: preview.tempId, module, mapping, defaults,
originalFilename: file?.name ?? preview.filename,
duplicateDecisions,
});
setResult({
ok: true,
msg: `✔ Import terminé : ${r.inserted} / ${r.total} lignes insérées`
+ (r.duplicates > 0 ? `, ${r.duplicates} doublon(s) ignoré(s)` : '')
+ (r.skipped > 0 ? `, ${r.skipped} ignorée(s)` : '')
+ '.'
+ (r.errors?.length > 0 ? ` (${r.errors.length} avertissement(s))` : ''),
});
setAnomalies(r.anomalies || []);
setPreview(null); setFile(null); setMapping({}); setDefaults({});
api.get('/imports/history').then(setHistory).catch(() => {});
if (module === 'plateformes') api.get('/plateformes').then(setPlats).catch(() => {});
const msg = `✔ Import terminé : ${r.inserted} / ${r.total} lignes insérées`
+ (r.duplicates > 0 ? `, ${r.duplicates} doublon(s) ignoré(s)` : '')
+ (r.skipped > 0 ? `, ${r.skipped} ignorée(s)` : '')
+ '.'
+ (r.errors?.length > 0 ? ` (${r.errors.length} avertissement(s))` : '');
// Le résultat est gardé en session pour être ré-affiché juste après le rechargement
// complet de la page (voir useEffect au montage), le temps que l'historique/les listes
// se rafraîchissent dans un état propre.
sessionStorage.setItem('cl_import_last_result', msg);
setIaJson(''); setIaErr(null);
window.location.reload();
} catch (e) { setErr(e.message); }
finally { setBusy(false); }
finally { setBusy(false); setDupBusy(false); }
};
const apply = async () => {
if (!preview) return;
setBusy(true); setErr(null);
try {
const check = await api.post('/imports/check-duplicates', {
tempId: preview.tempId, module, mapping, defaults,
originalFilename: file?.name ?? preview.filename,
});
if (check.duplicates && check.duplicates.length > 0) {
// Par défaut : ignorer (comportement historique), sauf pour taux_pfu où le comportement
// historique est d'écraser le taux existant.
const initial = {};
for (const d of check.duplicates) initial[d.row] = module === 'taux_pfu' ? 'accept' : 'skip';
setDupList(check.duplicates);
setDupDecisions(initial);
setDupModalOpen(true);
setBusy(false);
return;
}
await runApply({});
} catch (e) {
setErr(e.message);
setBusy(false);
}
};
const confirmDuplicatesAndApply = async () => {
setDupBusy(true);
await runApply(dupDecisions);
};
const fixDateOuverture = async (anomaly) => {
@@ -701,28 +1109,89 @@ export default function ImportsSection() {
finally { setFixingPlatId(null); }
};
const generateTemplate = async (format) => {
if (!preview) return;
setTemplateBusy(true);
try {
const r = await api.post('/imports/template', {
tempId: preview.tempId, module, mapping, defaults,
originalFilename: file?.name ?? preview.filename,
});
const base = `Import ${def?.label ?? module} - modele a completer ${timestampSuffix()}`;
if (format === 'csv') dlBlob(templateToCSV(r.headers, r.rows), `${base}.csv`, 'text/csv;charset=utf-8');
if (format === 'xls') dlBlob(templateToXLS(r.headers, r.rows), `${base}.xlsx`, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
if (format === 'json') dlBlob(templateToJSON(r.rows), `${base}.json`, 'application/json');
} catch (e) { setErr(e.message); }
finally { setTemplateBusy(false); }
};
const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1;
return (
<>
<div className="card">
<h3 style={{ margin: '0 0 4px' }}>1. Contexte de l'import</h3>
<h3 style={{ margin: '0 0 4px' }}>Étape 1 - Précisez le contexte de l'import</h3>
<p className="text-muted" style={{ margin: '0 0 16px', fontSize: 'var(--fs-sm)' }}>
Choisissez le type de données à importer : les blocs ci-dessous s'adaptent automatiquement
(champs attendus, prompt IA, dossier investissement).
</p>
<div className="row">
<div style={{ flex: 1 }}>
<div style={{ flex: '0 0 auto', width: 280 }}>
<label>Module cible</label>
<select value={module} onChange={e => {
setModule(e.target.value);
const v = e.target.value;
const stillScopable = v === 'depots_retraits' || v === 'investissements' || v === 'remboursements';
setModule(v);
setPreview(null); setMapping({}); setResult(null); setErr(null);
if (!stillScopable) setScopePlateformeId('');
if (v !== 'investissements' && activeMode === 'dossier') setActiveMode(null);
}}>
{Object.entries(MODULES).map(([k, v]) => (
<option key={k} value={k}>{v.label}</option>
))}
</select>
</div>
{platformScopable && (
<div style={{ flex: '0 0 auto', width: 280 }}>
<label>Plateforme</label>
<select value={scopePlateformeId} onChange={e => {
setScopePlateformeId(e.target.value);
setPreview(null); setMapping({}); setDefaults({}); setResult(null); setErr(null);
}}>
<option value="">Toutes les plateformes (multi-plateforme)</option>
{plats.map(p => <option key={p.id} value={p.id}>{p.nom}</option>)}
</select>
</div>
)}
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 14, flexWrap: 'wrap' }}>
<button
type="button"
className={activeMode === 'fichier' ? 'primary' : 'secondary'}
style={{ flex: '0 0 auto', width: 'auto' }}
onClick={() => setActiveMode(m => m === 'fichier' ? null : 'fichier')}
>
Import par fichier source
</button>
<button
type="button"
className={activeMode === 'ia' ? 'primary' : 'secondary'}
style={{ flex: '0 0 auto', width: 'auto' }}
onClick={() => setActiveMode(m => m === 'ia' ? null : 'ia')}
>
Import avec l'aide de l'IA
</button>
{module === 'investissements' && (
<button
type="button"
className={activeMode === 'dossier' ? 'primary' : 'secondary'}
style={{ flex: '0 0 auto', width: 'auto' }}
onClick={() => setActiveMode(m => m === 'dossier' ? null : 'dossier')}
>
Import par dossier d'investissement
</button>
)}
</div>
{def?.note && (
@@ -746,60 +1215,109 @@ export default function ImportsSection() {
)}
</div>
<div className="card">
<h3 style={{ margin: '0 0 4px' }}>2. Fichier source</h3>
<p className="text-muted" style={{ margin: '0 0 16px', fontSize: 'var(--fs-sm)' }}>
Importez des données depuis un fichier Excel, CSV ou JSON pour le module <strong>{def?.label ?? module}</strong>.
</p>
<div className="row">
<div style={{ flex: 2 }}>
<label>Fichier .xlsx, .csv ou .json</label>
<input type="file" accept=".xlsx,.xls,.csv,.json" onChange={e => {
setFile(e.target.files[0]);
setPreview(null); setResult(null); setErr(null);
}} />
</div>
<div>
<button className="primary" onClick={onPreview} disabled={!file || busy || missingInv}>
{busy ? '' : 'Analyser'}
</button>
{activeMode === 'fichier' && (
<div className="card">
<h3 style={{ margin: '0 0 4px' }}>Étape 2 - Précisez le fichier source</h3>
<p className="text-muted" style={{ margin: '0 0 16px', fontSize: 'var(--fs-sm)' }}>
Importez des données depuis un fichier Excel, CSV ou JSON pour le module <strong>{def?.label ?? module}</strong>.
{scopePlateforme && <> Toutes les lignes seront associées à <strong>{scopePlateforme.nom}</strong>.</>}
</p>
<div className="row">
<div style={{ flex: 2 }}>
<label>Fichier .xlsx, .csv ou .json</label>
<input type="file" accept=".xlsx,.xls,.csv,.json" onChange={e => {
setFile(e.target.files[0]);
setPreview(null); setResult(null); setErr(null);
}} />
</div>
<div>
<button className="primary" onClick={onPreview} disabled={!file || busy || missingInv}>
{busy ? '…' : 'Analyser'}
</button>
</div>
</div>
</div>
)}
{err && <div className="error" style={{ marginTop: 12 }}>{err}</div>}
<ResultBanner result={result} onDismiss={() => setResult(null)} style={{ marginTop: 12 }} />
{activeMode === 'dossier' && module === 'investissements' && (
<DossierImport
activeId={activeId}
navigate={navigate}
dossierFile={dossierFile} setDossierFile={setDossierFile}
dossierPreview={dossierPreview} setDossierPreview={setDossierPreview}
dossierResult={dossierResult} setDossierResult={setDossierResult}
dossierBusy={dossierBusy} setDossierBusy={setDossierBusy}
dossierErr={dossierErr} setDossierErr={setDossierErr}
dossierInputRef={dossierInputRef}
reloadHistory={() => api.get('/imports/history').then(setHistory).catch(() => {})}
/>
)}
{anomalies.length > 0 && (
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 8 }}>
{anomalies.map(a => (
<div key={a.plateforme_id} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
padding: '10px 14px', borderRadius: 8,
background: 'var(--surface-2)', border: '1px solid var(--warning)',
}}>
<span style={{ fontSize: 13 }}>
⚠ <strong>{a.plateforme_nom}</strong> : des données importées remontent au{' '}
<strong>{fmtDate(a.date_detectee)}</strong>, avant la date d'ouverture déclarée
({fmtDate(a.date_ouverture_actuelle)}).
</span>
<button
className="secondary"
onClick={() => fixDateOuverture(a)}
disabled={fixingPlatId === a.plateforme_id}
style={{ flexShrink: 0, whiteSpace: 'nowrap' }}
>
{fixingPlatId === a.plateforme_id ? '…' : `Corriger la date d'ouverture (${fmtDate(a.date_detectee)})`}
</button>
</div>
))}
</div>
)}
</div>
{activeMode === 'ia' && (
<div className="card">
<h3 style={{ margin: '0 0 4px' }}>Étape 2 - Aidez-vous d'une IA pour préparer les données à importer</h3>
<p className="text-muted" style={{ margin: '0 0 14px', fontSize: 'var(--fs-sm)' }}>
Utilisez un assistant IA pour convertir l'export de votre plateforme (CSV, Excel, PDF) en données structurées,
sans mappage manuel des colonnes. Module cible actuel : <strong>{def?.label ?? module}</strong>
{' '}(modifiable en section 1 ci-dessus).
{scopePlateforme && <> Import limité à <strong>{scopePlateforme.nom}</strong> : le prompt généré ci-dessous en tient compte.</>}
</p>
<IaImportPasteBlock
moduleLabel={def?.label ?? module}
iaJson={iaJson} setIaJson={setIaJson}
iaErr={iaErr} setIaErr={setIaErr}
onAnalyze={analyzeIaJson}
busy={busy}
/>
<IaImportPromptBlock
module={module}
moduleLabel={def?.label ?? module}
plats={plats}
investissements={scopedInvestissements}
scopePlateforme={scopePlateforme}
open={iaPromptOpen}
setOpen={setIaPromptOpen}
/>
</div>
)}
{(err || result || anomalies.length > 0) && (
<div className="card">
{err && <div className="error">{err}</div>}
<ResultBanner result={result} onDismiss={() => setResult(null)} />
{anomalies.length > 0 && (
<div style={{ marginTop: err || result ? 12 : 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
{anomalies.map(a => (
<div key={a.plateforme_id} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
padding: '10px 14px', borderRadius: 8,
background: 'var(--surface-2)', border: '1px solid var(--warning)',
}}>
<span style={{ fontSize: 13 }}>
<strong>{a.plateforme_nom}</strong> : des données importées remontent au{' '}
<strong>{fmtDate(a.date_detectee)}</strong>, avant la date d'ouverture déclarée
({fmtDate(a.date_ouverture_actuelle)}).
</span>
<button
className="secondary"
onClick={() => fixDateOuverture(a)}
disabled={fixingPlatId === a.plateforme_id}
style={{ flexShrink: 0, whiteSpace: 'nowrap' }}
>
{fixingPlatId === a.plateforme_id ? '' : `Corriger la date d'ouverture (${fmtDate(a.date_detectee)})`}
</button>
</div>
))}
</div>
)}
</div>
)}
{preview && (
<>
<div className="card">
<h3 style={{ marginTop: 0 }}>3. Mappage des colonnes</h3>
<h3 style={{ marginTop: 0 }}>Mappage des colonnes</h3>
<p className="text-muted" style={{ fontSize: 12 }}>
Fichier : <strong>{preview.filename}</strong> — feuille <em>{preview.sheetName}</em> — {preview.allRowCount} lignes.
{' '}Champs marqués <span style={{ color: 'var(--danger)' }}>*</span> obligatoires.
@@ -829,7 +1347,7 @@ export default function ImportsSection() {
{preview.headers.map(h => <option key={h} value={h}>{h}</option>)}
</select>
{NAME_RESOLVABLE_FIELDS.has(t) && mapping[t] && (() => {
const refList = t === 'plateforme_id' ? plats : investissements;
const refList = t === 'plateforme_id' ? plats : scopedInvestissements;
const nameKey = t === 'plateforme_id' ? 'nom' : 'nom_projet';
const rawValues = [...new Set(
preview.sampleRows
@@ -870,7 +1388,7 @@ export default function ImportsSection() {
) : t === 'investissement_id' ? (
<select value={defaults[t] || ''} onChange={e => setDefaults({ ...defaults, [t]: e.target.value })}>
<option value="">—</option>
{investissements.map(i => <option key={i.id} value={i.id}>{i.nom_projet}</option>)}
{scopedInvestissements.map(i => <option key={i.id} value={i.id}>{i.nom_projet}</option>)}
</select>
) : (
<input value={defaults[t] || ''} onChange={e => setDefaults({ ...defaults, [t]: e.target.value })}
@@ -887,6 +1405,13 @@ export default function ImportsSection() {
<button className="primary" onClick={apply} disabled={busy || missingInv}>
{busy ? '…' : `Importer ${preview.allRowCount} lignes`}
</button>
<TemplateDropdown
disabled={missingInv}
busy={templateBusy}
onCSV={() => generateTemplate('csv')}
onXLS={() => generateTemplate('xls')}
onJSON={() => generateTemplate('json')}
/>
</div>
</div>
@@ -906,44 +1431,6 @@ export default function ImportsSection() {
</>
)}
{/* Le dossier investissement (capital + remboursements + historique) n'a de sens
que dans le contexte du module Investissements. */}
{module === 'investissements' && (
<DossierImport
activeId={activeId}
navigate={navigate}
dossierFile={dossierFile} setDossierFile={setDossierFile}
dossierPreview={dossierPreview} setDossierPreview={setDossierPreview}
dossierResult={dossierResult} setDossierResult={setDossierResult}
dossierBusy={dossierBusy} setDossierBusy={setDossierBusy}
dossierErr={dossierErr} setDossierErr={setDossierErr}
dossierInputRef={dossierInputRef}
reloadHistory={() => api.get('/imports/history').then(setHistory).catch(() => {})}
/>
)}
<div className="card">
<h3 style={{ margin: '0 0 4px' }}>Import généré par IA</h3>
<p className="text-muted" style={{ margin: '0 0 14px', fontSize: 'var(--fs-sm)' }}>
Utilisez un assistant IA pour convertir l'export de votre plateforme (CSV, Excel, PDF…) en données structurées,
sans mappage manuel des colonnes. Module cible actuel : <strong>{def?.label ?? module}</strong>
{' '}(modifiable en section 1 ci-dessus).
</p>
<IaImportPasteBlock
moduleLabel={def?.label ?? module}
iaJson={iaJson} setIaJson={setIaJson}
iaErr={iaErr} setIaErr={setIaErr}
onAnalyze={analyzeIaJson}
busy={busy}
/>
<IaImportPromptBlock
module={module}
moduleLabel={def?.label ?? module}
plats={plats}
investissements={investissements}
/>
</div>
<div className="card">
<h3 style={{ marginTop: 0 }}>Historique des imports</h3>
<table>
@@ -971,6 +1458,18 @@ export default function ImportsSection() {
</tbody>
</table>
</div>
<DuplicatesModal
open={dupModalOpen}
module={module}
moduleLabel={def?.label ?? module}
duplicates={dupList}
decisions={dupDecisions}
setDecisions={setDupDecisions}
onConfirm={confirmDuplicatesAndApply}
onCancel={() => { setDupModalOpen(false); setDupList([]); setDupDecisions({}); }}
busy={dupBusy}
/>
</>
);
}
+22 -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); }
@@ -1417,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), où 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); }
},
);
}