Compare commits
38 Commits
e11c767c1c
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 41c464e6a3 | |||
| 441da1975a | |||
| 9eb19efd92 | |||
| 6e58731a20 | |||
| 56dd1f89bd | |||
| 5a0a1c03ac | |||
| fa318f240c | |||
| 4d8fb9bab8 | |||
| c843464ccd | |||
| f9c5316303 | |||
| 3a9de15526 | |||
| 069e316362 | |||
| 84f1cb19d5 | |||
| 3e4c0dffa8 | |||
| 4ef7cbee04 | |||
| 86a047a0c5 | |||
| 63f38b5ff0 | |||
| 7189507998 | |||
| 7ace2c8f2e | |||
| 316a86c95a | |||
| 47c5ad34fa | |||
| 96effbe15a | |||
| ef4576d160 | |||
| 794f8684a2 | |||
| ec3c7f7cab | |||
| 0d8f92bc9a | |||
| 186ffc2596 | |||
| 1f75878d87 | |||
| 5e775da711 | |||
| 8bcd7e603d | |||
| 8834236840 | |||
| b138bc53ee | |||
| 11790c63fe | |||
| 69d006d358 | |||
| ff77ee977d | |||
| 53742568ce | |||
| 2281894802 | |||
| 89190c4561 |
@@ -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
|
||||
@@ -442,6 +443,14 @@ const isBonus = BONUS_VALUES.includes(form.investissement_id);
|
||||
- **Déployé sur les 8 écrans contenant un champ mot de passe** : Login, Register, ResetPassword (×2 champs), InvitationRegister (×2 champs), MonCompte (SecurityForm ×3 champs + changement email ×1 + désactivation 2FA ×1), admin/CreateUserSection, admin/UsersSection
|
||||
- **Règle à respecter** : tout nouveau champ mot de passe doit utiliser `<PasswordInput>` plutôt que `<input type="password">` brut, pour garder l'UX cohérente sur toute l'app
|
||||
|
||||
### Suppression définitive de compte (self-service)
|
||||
- Route `DELETE /api/auth/me` (requireAuth, body `{ password }`) dans `auth.js` : vérifie le mot de passe (bcrypt), bloque si l'utilisateur est le **dernier admin** (`COUNT(*) WHERE role='admin' <= 1`), logge un audit `account_self_deleted` (catégorie `account`, `details.initiated_by:'self'`) **avant** la suppression, notifie tous les autres admins (type `security`, lien `/admin?section=audit-logs`), puis `DELETE FROM users WHERE id=?`
|
||||
- **Le nettoyage des données ne fait AUCUN delete manuel par table** — il repose entièrement sur les FK `ON DELETE CASCADE` déjà en place sur `user_id`/`investisseur_id`/etc. (`db.pragma('foreign_keys = ON')` activé globalement dans `db/index.js`). C'est le même mécanisme que `DELETE /api/admin/users/:id` (admin.js) qui fait déjà un simple `DELETE FROM users` sans étape de nettoyage manuel
|
||||
- `audit_logs.actor_id`/`target_user_id` sont en `ON DELETE SET NULL` (pas CASCADE) : le log survit à la suppression de l'utilisateur, les infos identifiantes (email, display_name, role) sont dupliquées dans `details` JSON pour rester lisibles même une fois les FK à NULL
|
||||
- Frontend : `AuthContext.deleteAccount(password)` → `api.del('/auth/me', {password})` puis `logout()` ; `api.del` accepte maintenant un `body` optionnel (`api.js`)
|
||||
- `DeleteAccountSection` dans `MonCompte.jsx`, en bas de l'onglet **Mon compte** (après le bloc Préférences, pas dans Sécurité) : carte bordée rouge, warning, reveal formulaire mot de passe au clic, boutons alignés à droite (cohérent avec le reste de la page)
|
||||
- Après suppression : redirection vers `/login?deleted=1`, `Login.jsx` affiche une bannière verte de confirmation si ce paramètre est présent
|
||||
|
||||
### Bug — profil principal / compte courant non créés hors /auth/register
|
||||
- **Constat** : seul `/api/auth/register` (auto-inscription) créait le profil investisseur principal (`is_principal=1`) ET le compte courant associé. Les deux autres parcours de création de compte en étaient dépourvus :
|
||||
- `POST /api/admin/users` (admin.js, `CreateUserSection.jsx`) : créait l'investisseur mais **sans `is_principal=1`** et **sans compte courant**
|
||||
@@ -451,3 +460,262 @@ const isBonus = BONUS_VALUES.includes(form.investissement_id);
|
||||
1. `admin.js` (`POST /users`) et `invitations.js` (`POST /:token/register`) répliquent maintenant exactement la logique de `auth.js` : `INSERT INTO investisseurs (..., is_principal) VALUES (..., 1)` + `INSERT INTO comptes (user_id, nom, type, investisseur_id)` avec `nom = 'Compte courant — ' + fullName`
|
||||
2. Backfill idempotent ajouté en fin de `backend/src/db/index.js` (avant `export default db`) qui tourne à chaque démarrage : (1) crée un investisseur principal pour tout user qui n'en a aucun, (2) marque principal le plus ancien investisseur `famille` pour tout user qui n'a pas de principal, (3) crée le compte courant manquant pour tout investisseur principal qui n'en a pas. Toutes les requêtes utilisent `NOT EXISTS` → sans effet une fois les données corrigées.
|
||||
- **Règle à retenir** : toute nouvelle voie de création de compte utilisateur doit répliquer les 2 inserts de `auth.js` (`investisseurs` avec `is_principal=1` + `comptes` type `compte_courant`) — ne pas dupliquer seulement l'insert `investisseurs`.
|
||||
|
||||
---
|
||||
|
||||
## Session 9 — Import de données & cohérence mono/multi-détenteur (2026-07-04)
|
||||
|
||||
### Ajout de plateforme depuis le référentiel — un seul champ demandé
|
||||
- `PlateformesSection.jsx` (`PlatPickerModal`) : au clic sur une plateforme du référentiel, une vue de confirmation demande **uniquement** la date d'ouverture du compte (« Pouvez-vous SVP préciser la date d'ouverture de votre compte sur cette plateforme ? »), pré-remplie à la date du jour (`localToday()` — jamais `.toISOString()`, cf. piège timezone ci-dessous). Tout le reste (domiciliation, fiscalité…) vient du référentiel.
|
||||
|
||||
### Colonne "Détenteur" — masquée si mono-détenteur
|
||||
- Pattern à répliquer partout : `const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1;` (ou `investisseurs.length > 1` si pas de liste de plateformes disponible)
|
||||
- Déployé sur : `Investissements.jsx`, `Remboursements.jsx`, `Plateformes.jsx`, `PlateformesSection.jsx`, `ComptesSection.jsx`, `DepotsRetraits.jsx` — colonnes `<th>`/`<td>` conditionnées + `colSpan` ajusté + tfoot spacer
|
||||
- **Idem sur les exports** (XLS/CSV/JSON) : passer `multiDetenteur` en paramètre à la fonction d'export (ex. `mouvToXLS(rows, multiDetenteur)`) et spreader conditionnellement `...(multiDetenteur ? { 'Détenteur': ... } : {})`
|
||||
|
||||
### DepotsRetraits — bug empty-state après premier dépôt (plateforme flat_tax)
|
||||
- **Cause** : pour une plateforme `flat_tax`, `submit()` ouvre `corrModal` (vérification du solde déclaré) au lieu d'appeler `load()` directement ; l'early-return de l'empty-state (`!loading && !modalOpen && allRows.length === 0`) masquait la `<CorrectionModal>` rendue plus bas dans le JSX.
|
||||
- **Fix** : ajouter `!corrModal.open` à la condition de l'early-return.
|
||||
|
||||
### Import — résolution de noms (plateforme/investissement) au lieu d'ID stricts
|
||||
- Backend `imports.js` : `resolveRefId(value, idSet, nameMap, label)` accepte un ID numérique OU un nom texte (normalisé accents/casse via `normalizeName`) pour `plateforme_id` (modules `depots_retraits`/`investissements`) et `investissement_id` (module `remboursements`)
|
||||
- Frontend : auto-mapping des colonnes par synonymes (`FIELD_SYNONYMS` + `normalizeHeader()`) en plus du match exact ; **auto-remplissage** du champ "Valeur par défaut" quand toutes les lignes d'échantillon résolvent vers la même plateforme/investissement (`resolvePreviewMatch`)
|
||||
|
||||
### Import — protection anti-doublon
|
||||
- Clé de détection par module (avant insertion, dans la transaction) :
|
||||
- `depots_retraits` : investisseur + plateforme + date + type + montant (tolérance 0,005)
|
||||
- `investissements` : investisseur + plateforme + nom_projet + date_souscription
|
||||
- `remboursements` : investissement + date_remb + capital + interets_bruts (tolérance 0,005)
|
||||
- `plateformes` : déjà couvert par `UNIQUE(nom)` → reclassé en doublon plutôt qu'erreur
|
||||
- Compteur `duplicates` distinct de `skipped` (vraies erreurs), colonne DB `imports.rows_duplicates`, affiché dans l'historique
|
||||
|
||||
### Import — anomalie date antérieure à la date d'ouverture de la plateforme
|
||||
- Pour `depots_retraits`/`investissements` uniquement (pas `remboursements`, lien plateforme trop indirect) : la date la plus ancienne rencontrée par plateforme dans le fichier importé (doublon ou non) est comparée à `plateformes.date_ouverture`
|
||||
- Si anomalie : réponse `/apply` inclut `anomalies: [{ plateforme_id, plateforme_nom, date_ouverture_actuelle, date_detectee }]`
|
||||
- Nouvelle route `PATCH /api/plateformes/:id/date-ouverture` (payload minimal `{ date_ouverture }`, ne PAS repasser par le `PUT /:id` complet)
|
||||
- `ImportsSection.jsx` affiche une bannière par anomalie avec bouton "Corriger la date d'ouverture (JJ/MM/AAAA)"
|
||||
|
||||
### Import généré par IA — nouveau bloc dans ImportsSection.jsx
|
||||
- Bloc "Import généré par IA" : prompt dynamique (`DEFAULT_IA_IMPORT_PROMPT`, template `{{MODULE_LABEL}}`/`{{FIELDS_LIST}}`/`{{REFERENCE_SECTION}}`, éditable et persisté `localStorage`) listant les champs attendus du module cible + les plateformes/investissements existants ; l'utilisateur colle le JSON généré par l'IA (avec ou sans fence ```json```) qui est réinjecté dans le pipeline preview/apply existant via un `Blob`/`File` virtuel (pas de code dupliqué)
|
||||
- **Règle imposée au prompt** : si le fichier ne permet pas d'identifier avec certitude la plateforme (ou l'investissement pour les remboursements), l'IA doit poser la question à l'utilisateur en ne proposant QUE les noms existants comme réponses possibles, et attendre la réponse avant de générer le JSON — plutôt que de deviner ou d'omettre le champ silencieusement
|
||||
- Page restructurée en 3 blocs séquentiels : "1. Contexte de l'import" (sélecteur de module) → "2. Fichier source" → "3. Mappage des colonnes" ; le bloc "Dossier investissement" n'apparaît que si le module actif est `investissements`
|
||||
|
||||
### Bug — suppression de compte cassée par plateforme_id ON DELETE RESTRICT
|
||||
- **Symptôme** : `DELETE /api/auth/me` échouait avec `SqliteError: FOREIGN KEY constraint failed` (code `SQLITE_CONSTRAINT_TRIGGER`) à `auth.js:319`.
|
||||
- **Cause** : `depots_retraits.plateforme_id` et `investissements.plateforme_id` étaient en `ON DELETE RESTRICT` (seuls FK RESTRICT du schéma) — lors du `DELETE FROM users`, les branches de cascade `users→plateformes` et `users→investisseurs→depots_retraits/investissements` sont indépendantes ; SQLite peut supprimer la plateforme avant les lignes qui la référencent encore, déclenchant RESTRICT.
|
||||
- **Fix** : migration `fixPlateformeCascade` dans `db/index.js` (recréation table via `__repair_*`, RESTRICT→CASCADE) + `schema.sql` mis à jour + protection déplacée côté application dans `DELETE /api/plateformes/:id` (vérification explicite du nombre d'investissements/dépôts-retraits avant suppression, message clair).
|
||||
- **Règle à retenir** : toute nouvelle table référençant `plateformes`/`investisseurs`/`users` doit être en `CASCADE` (ou `SET NULL`), jamais `RESTRICT` — sinon la suppression de compte se recasse. Les protections anti-suppression-accidentelle doivent être implémentées côté route, pas côté contrainte FK.
|
||||
|
||||
### Piège outillage — mount bash périmé après édition Edit/Write
|
||||
- Après une édition via l'outil `Edit`/`Write` (côté fichier réel), une vérification immédiate via `mcp__workspace__bash` (wc/tail/node --check) peut montrer une **version périmée/tronquée** du fichier alors que le fichier réel est complet et correct — le mount bash ne se resynchronise pas instantanément après une écriture Windows-side.
|
||||
- **Règle** : vérifier l'intégrité d'un fichier édité via l'outil `Read` (relire la queue, vérifier la fermeture propre), pas via bash. N'utiliser bash pour vérifier que si l'écriture a elle-même été faite depuis bash (ex. reconstruction `python3` après troncature confirmée par `Read`).
|
||||
|
||||
---
|
||||
|
||||
## Session 10 — Bug racine dates prêts différés + audit trail + statuts auto (2026-07-12)
|
||||
|
||||
### 🔴 Bug racine trouvé et corrigé — migration `db/index.js` recalculait `date_cible` à CHAQUE démarrage
|
||||
- **Symptôme initial** : des dizaines de prêts `differe` avec `date_cible` aberrante (années 2100 à 2650), sans aucune trace dans `investissement_historique`, y compris des cas de "ping-pong" de statut (`en_cours` ↔ `en_retard`) sur les mêmes prêts toutes les quelques minutes.
|
||||
- **Cause réelle** (migration "renommage `date_debut`→`date_premiere_echeance`, `date_echeance`→`date_cible`", `backend/src/db/index.js` ~ligne 229) : le bloc de "correction de formule historique" (`SET date_cible = date(date_premiere_echeance, '+(duree_mois-1)' months)`) n'avait **aucune garde** (`WHERE date_cible IS NULL`) — il tournait donc à **chaque redémarrage serveur**, pour **tous** les investissements. Combiné à une 2e migration juste après (`date_premiere_echeance = date_cible` pour les prêts différés, censée maintenir l'égalité des deux dates), cela formait une **boucle infinie** : à chaque redémarrage, `date_cible` dérivait de +(duree_mois-1) mois supplémentaires — jamais tracé, car du code de migration, pas une action utilisateur ni une route API.
|
||||
- **Pourquoi ça n'a été détecté que maintenant** : le serveur redémarre très souvent en dev (`node --watch`), donc chaque édition de code déclenchait un cycle de dérive supplémentaire sur les prêts différés déjà touchés.
|
||||
- **Fix** : le bloc de correction ne s'exécute désormais que si `migrationEnCours` est vrai (colonnes `date_debut`/`date_echeance` encore présentes, càd le jour réel du renommage) — plus jamais au démarrage normal. La migration `date_premiere_echeance = date_cible` (prêts différés) enregistre maintenant un historique précis (`correction_auto_echeancier`) quand elle modifie quelque chose.
|
||||
- **Point d'attention pour le futur** : toute migration de données (pas juste `ALTER TABLE ADD COLUMN`) dans `db/index.js` doit être strictement idempotente/one-shot — soit via clause `WHERE champ IS NULL`, soit gardée derrière la détection de l'événement historique qui la justifie. Ne jamais laisser un `UPDATE` sans garde tourner à chaque boot.
|
||||
|
||||
### Autres endroits déjà audités/corrigés pour la même classe de bug (dates modifiées sans trace)
|
||||
- `POST /api/imports/dossier` (`imports.js`) : la branche "SCÉNARIO UPDATE" (upsert par nom_projet+date_souscription) écrasait `date_cible`/`date_premiere_echeance`/`montant_investi`/`taux_interet`/`duree_mois`/`type_remb`/`statut` avec seulement un historique générique ("Mise à jour dossier"), sans le détail des champs modifiés. Fix : réutilise `detectChangements`/`recordHistory`/`detectTypeEvenement` (désormais **exportés** depuis `investissements.js`) pour logger un diff précis champ par champ, comme l'édition manuelle.
|
||||
- `backend/fix_dates_cible.mjs` : script autonome (`node fix_dates_cible.mjs`), jamais appelé automatiquement, corrige les `date_cible > 2100-01-01` — toujours présent mais pas la source du bug de cette session.
|
||||
|
||||
### Nouveautés Nettoyage de données (`Settings.jsx` → section `imports`, composant `DataCleanupSection.jsx`)
|
||||
1. **"Corriger les dates des prêts différés"** (`POST /investissements/fix-differe-dates`) :
|
||||
- Seuil d'écart désormais **configurable** via un select dans la modale : 3 / 6 / 12 / 18 / 24 mois (défaut 24, avant en dur "2 ans"). Body `{ seuilMois }`, validé côté backend contre `[3,6,12,18,24]`.
|
||||
- Régénère maintenant `simul_remboursements` via `generateSimul()` après correction (avant : échéancier laissé désynchronisé).
|
||||
- Déclenche immédiatement `checkStatutsRetard()` après correction (sinon un prêt dont la date recalculée tombe dans le passé restait affiché "en_cours" jusqu'au prochain minuit).
|
||||
2. **"Vérifier la cohérence de l'échéancier des prêts différés"** (nouveau, `POST /investissements/check-echeancier-differe`) — inséré juste après le précédent. Vérifie que `simul_remboursements` contient exactement 1 échéance à la date `date_premiere_echeance`/`date_cible` du prêt ; régénère sinon (`generateSimulWithReinvestissements` si réinvestissements présents, sinon `generateSimul`), log `correction_auto_echeancier`.
|
||||
|
||||
### Job `autoStatut.js` — statuts automatiques enrichis
|
||||
- `checkStatutsRetard()` fait maintenant **les deux sens** : `en_cours→en_retard` (comme avant, log `passage_auto_retard`) ET `en_retard→en_cours` (nouveau, log `retour_auto_en_cours`) — mais uniquement si (a) `date_cible` est entre aujourd'hui et +30 ans (garde-fou anti date-encore-aberrante-mais-"future") ET (b) le dernier événement d'historique touchant le statut était bien `passage_auto_retard` (jamais d'annulation automatique d'un passage en retard décidé manuellement).
|
||||
- Chaque transition génère une **notification utilisateur** (table `notifications`) : type `warning` pour le passage en retard, type `success` pour le retour en cours, avec lien direct `/investissements/:id`.
|
||||
- `TRACKED_FIELDS`, `recordHistory`, `detectChangements`, `detectTypeEvenement` sont maintenant `export` depuis `investissements.js` (réutilisés par `imports.js`).
|
||||
|
||||
### Piège outillage — lecture de la DB SQLite en direct depuis le sandbox bash (WAL + serveur actif)
|
||||
- Le fichier réel `backend/data/crowdlending.db` est en mode WAL et activement écrit par le serveur Node de l'utilisateur pendant la session. Une copie manuelle (`cp` séparé du `.db`/`.db-wal`/`.db-shm`) pendant que le serveur écrit produit des **lectures tronquées ("database disk image is malformed") ou incohérentes entre deux requêtes successives** — ce n'est PAS la preuve que les données changent réellement à chaque lecture.
|
||||
- `better-sqlite3` du repo est un binaire natif **Windows** (`node_modules/better-sqlite3/build/Release/better_sqlite3.node`) → `invalid ELF header` dans le sandbox Linux. Utiliser `python3` + module `sqlite3` standard à la place, sur une copie locale dans `/tmp`.
|
||||
- Pour une lecture fiable d'un état ponctuel : privilégier les preuves de haut niveau déjà présentes dans l'app (page Historique du prêt, page Notifications avec horodatage) plutôt que des requêtes SQL répétées sur une DB en cours d'écriture concurrente.
|
||||
|
||||
---
|
||||
|
||||
## Session 11 — Révision des conditions de prêt (2026-07-13)
|
||||
|
||||
### Nouvelle fonctionnalité : révision des conditions (taux / date cible)
|
||||
Distincte de `investissement_historique` (audit générique auto-détecté sur tout changement de champ) : trace un **événement métier explicite** (retard projet, renégociation…) avec **motif obligatoire**, dans sa propre table.
|
||||
|
||||
- Table `investissement_revisions` (migration dans `db/index.js`) : `date_effet`, `ancien_taux`/`nouveau_taux`, `ancienne_date_cible`/`nouvelle_date_cible`, `ancien_duree_mois`/`nouveau_duree_mois`, `motif TEXT NOT NULL`.
|
||||
- Routes dans `investissements.js` (pas de nouveau fichier) : `POST /:id/revisions`, `DELETE /:id/revisions/:rid` (**seule la dernière révision est supprimable**, rollback vers l'état précédent + régénération de l'échéancier). `GET /:id` retourne `revisions` au même niveau que `historique`/`simul`.
|
||||
- **Point technique clé** : `generateSimul()` se base sur `duree_mois`, **pas** sur `date_cible` (champ d'affichage/contractuel). Donc si `nouvelle_date_cible` est fournie, `duree_mois` est recalculé (`monthsDiff(date_premiere_echeance, nouvelle_date_cible) + 1`) sinon la nouvelle date cible ne serait que cosmétique. Ancien/nouveau `duree_mois` tracés en base pour un rollback fidèle.
|
||||
- Helper `regenererEcheancier(investissementId)` dans `investissements.js` : appelle `generateSimulWithReinvestissements` si l'investissement a des réinvestissements, sinon `generateSimul`.
|
||||
- Frontend `InvestissementDetail.jsx` : modal "Réviser les conditions" avec **2 checkboxes explicites** ("Modifier le taux" / "Modifier la date cible") — pas de convention implicite "champ vide = inchangé" (ambiguë, corrigée suite à retour utilisateur). Carte "Révisions du prêt" placée **juste après "Informations du projet"**, avant "Remboursements enregistrés" (positionnement demandé explicitement par l'utilisateur). Badges "Révisé le" avec ancien barré → nouveau sur les champs Taux annuel / Date cible contractuelle. Avertissement non bloquant (texte violet `#a78bfa`) dans le formulaire "Modifier" standard si le champ a déjà été révisé — n'empêche pas l'édition directe (choix utilisateur : le formulaire standard reste une simple correction de fiche, pas un événement de révision).
|
||||
|
||||
### 🔴 Bug trouvé et corrigé — `generateSimulWithReinvestissements` ne préservait pas les échéances déjà payées
|
||||
- **Symptôme** : après une révision sur un prêt avec réinvestissement actif, la table "Projections de remboursements" perdait plusieurs mois d'échéances déjà payées (renumérotées à partir de 1 depuis la date d'effet de la révision), alors que les remboursements réels restaient intacts.
|
||||
- **Cause** : `generateSimul()` a une logique de "mode restructuration" (si `date_debut_simul` posé : conserve les échéances antérieures qui correspondent à un remboursement réel, supprime le reste, renumérote à partir des mois réellement écoulés) — mais `generateSimulWithReinvestissements()` ne l'avait **jamais eue** : elle supprimait tout l'échéancier et le régénérait en repartant à `numero_echeance = 1`. Ce chemin n'avait jamais été exercé avant (aucune fonctionnalité ne posait `date_debut_simul` sur un prêt ayant des réinvestissements).
|
||||
- **Fix** : dupliqué exactement la logique restructuration de `generateSimul()` dans `generateSimulWithReinvestissements()` (`backend/src/utils/schedule.js`).
|
||||
- **Remède pour les investissements déjà impactés** : menu ⋮ de "Projections de remboursements" → "Régénérer l'échéancier" (route `/simul/recalculate` → `adjustSimulForActuals` → `generateSimulWithReinvestissements`, maintenant corrigée).
|
||||
|
||||
### 🔴 Bug trouvé et corrigé — `taux_interet = 0` traité comme "absent"
|
||||
- **Cas réel** : plateforme de cloud mining à l'arrêt → révision du taux à 0 %. Bloqué côté validation (`z.number().positive()` rejette 0) ET aurait été silencieusement ignoré par les gardes `!taux_interet` (falsy pour `0`, `null` et `undefined` en JS) qui auraient empêché toute régénération d'échéancier même une fois la validation corrigée.
|
||||
- **Fix** : `RevisionSchema.nouveau_taux` → `.nonnegative()`. Gardes `!inv.taux_interet` → `inv.taux_interet == null` dans `schedule.js` (`generateSimul`, `generateSimulWithReinvestissements`, `adjustSimulForActuals`), `simul.js` (`POST /generate`), et 3 endroits d'affichage frontend (`InvestissementDetail.jsx` bouton "Régénérer l'échéancier", `SimulRemboursements.jsx`, `Remboursements.jsx` — message "taux manquant").
|
||||
- **Règle à retenir** : ne jamais tester un champ numérique métier potentiellement à 0 avec `!champ` — toujours `champ == null` (ou `??` pour les fallbacks, déjà en usage ailleurs dans le code pour `taux_interet`).
|
||||
|
||||
### Tooltip "taux implicite" — détection de revalorisation non annoncée
|
||||
- Colonne "Intérêts" du tableau "Remboursements enregistrés" (`InvestissementDetail.jsx`) : tooltip (`cell-tooltip`, même pattern que la colonne Imposition) calculant le taux annuel implicite brut/net à partir de l'intérêt réellement versé à cette échéance et du capital restant dû juste avant (montant investi + réinvestissements antérieurs − capital déjà remboursé aux échéances précédentes).
|
||||
- But : aider à repérer visuellement une revalorisation de taux non annoncée par la plateforme (le taux implicite change d'une ligne à l'autre sans qu'aucune révision n'ait été saisie).
|
||||
- Non calculé pour les prêts `differe` (un seul versement, pas de série à comparer) — guardé sur `freq_interets === 'mensuel' || 'trimestriel'`.
|
||||
|
||||
### Piège outillage — confirmé à nouveau (cf. session 9)
|
||||
Le mount bash de `crowdlending-app` était figé sur un instantané ancien (dates de fichiers plusieurs semaines avant la session), sans lien avec les éditions faites via l'outil `Edit`/`Write` dans cette session — `wc -l`/`node --check` sur le mount bash donnaient un fichier tronqué non représentatif. Solution utilisée : reconstruire le fichier édité dans le dossier `outputs` (accessible en écriture réelle depuis bash) via lecture complète par l'outil `Read`, puis `node --check` / `esbuild --loader=jsx` dessus pour une vérification syntaxique fiable — en complément (pas remplacement) de la relecture visuelle des zones éditées via `Read`.
|
||||
|
||||
---
|
||||
|
||||
## Session 12 — KPI XIRR sur la page Plateformes (2026-07-13)
|
||||
|
||||
### Rendement annualisé (XIRR) estimé pour les prêts en cours + renommage KPI
|
||||
Sur `InvestissementDetail.jsx`, le KPI "Rendement annualisé" a été renommé **"XIRR — Brut/Net"** et calculé désormais pour **tous les statuts** (plus seulement `rembourse`) : pour un prêt non soldé, on ajoute un flux de trésorerie synthétique final = capital restant dû, daté du jour ("valorisation à date"), en plus des flux réels (versement initial, réinvestissements, remboursements). Le label affiche "(estimé)" tant que le prêt n'est pas intégralement remboursé. Un popup (icône info + `cell-tooltip tooltip-down`) explique la méthodologie. Le modificateur CSS `.tooltip-down` (`top: calc(100% + 6px)`) a été ajouté car le tooltip par défaut s'ouvre vers le haut et était coupé en haut de viewport pour ce KPI proche du sommet de page.
|
||||
|
||||
### Fonction `xirr()` extraite en utilitaire partagé
|
||||
Déplacée de `InvestissementDetail.jsx` (définition locale) vers `frontend/src/utils/xirr.js` (export nommé `xirr`), pour réutilisation sans duplication. Newton-Raphson sur flux datés, retourne `null` si non convergent ou < 2 flux.
|
||||
|
||||
### Nouveau KPI XIRR sur la page Plateformes
|
||||
Ajouté en 6e position dans `dr-kpi-row` de `Plateformes.jsx` (grid passée de `repeat(5,1fr)` à `repeat(6,1fr)`), juste après "Intérêts perçus" — même format que la fiche investissement (label + icône info + tooltip explicatif, "(estimé)" si applicable).
|
||||
- Calcul agrégé (`plateformeXirr`, `useMemo`) sur `chartRows` (déjà filtré plateforme + détenteur + année) : flux datés individuels (pas des sommes) — investissement initial (négatif) + réinvestissements (négatif) + remboursements réels (positif, brut = capital+cashback+interets_bruts, net = `net_recu`), tous filtrés par la même coupure `cutoff` que les autres agrégats de la page (`${selectedYear}-12-31` si une année est sélectionnée, sinon `today()`).
|
||||
- Valorisation à date : pour chaque investissement de `chartRows` encore actif (capital restant dû > 0 à la coupure), un flux positif = capital restant est ajouté à la date de coupure — cohérent avec la logique déjà utilisée pour `totals.encours`.
|
||||
- Flux triés chronologiquement avant l'appel à `xirr()` (invariant mathématiquement par rapport à l'ancre `t0`, mais plus robuste/lisible).
|
||||
- Choix assumé : pas de `TrendBadge` (comparaison N-1) sur ce KPI — contrairement aux 5 autres — car un XIRR n'est pas additif d'une année sur l'autre comme un total cumulé ; à la place, un sous-texte indique la date de valorisation ("Valorisé au 31/12/2025" ou "Valorisé au aujourd'hui").
|
||||
|
||||
### Piège outillage — reconfirmé une 3e fois (sessions 9, 11, 12)
|
||||
Le mount bash reste figé sur un instantané qui ne reflète pas les éditions de la session (`wc -l` sur le mount stagnait à 1591 lignes alors que le fichier réel via l'outil `Read` en comptait 1675, et ce même après un nouveau `cp` explicite). Vérification faite intégralement via relecture complète du fichier par l'outil `Read` (comptage de lignes, relecture des zones éditées, vérification de fermeture des blocs). Ne plus perdre de temps à essayer de resynchroniser le mount bash pour de la vérification syntaxique sur ce projet — se fier à une relecture `Read` complète et méthodique en priorité.
|
||||
|
||||
---
|
||||
|
||||
## Session 13 — Objectifs annuels de versement (2026-07-13)
|
||||
|
||||
### Nouvelle fonctionnalité : suivi d'objectifs de versement annuel
|
||||
Demande initiale : pouvoir fixer un objectif de dépôts nets par an et suivre l'écart, en partant de la page Plateformes (onglet Dépôts/Retraits), avec une architecture réutilisable ailleurs.
|
||||
|
||||
**Décisions produit actées après clarification (questions posées avant implémentation, conformément à la règle "poser des questions avant tâche complexe")** :
|
||||
- Objectif défini **par investisseur et par année** (pas global, pas par plateforme) — en vue "tous les investisseurs", affichage = somme des objectifs individuels.
|
||||
- Le suivi est **toujours calculé sur le portefeuille entier** (toutes plateformes), indépendamment du filtre plateforme actif sur la page qui l'affiche.
|
||||
|
||||
### Modèle de données
|
||||
Nouvelle table `objectifs` (migration dans `db/index.js`, avant `export default db`) :
|
||||
```sql
|
||||
CREATE TABLE objectifs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
investisseur_id INTEGER NOT NULL REFERENCES investisseurs(id) ON DELETE CASCADE,
|
||||
type TEXT NOT NULL DEFAULT 'versement_annuel',
|
||||
annee INTEGER NOT NULL,
|
||||
montant REAL NOT NULL,
|
||||
notes TEXT,
|
||||
created_at/updated_at TEXT,
|
||||
UNIQUE(investisseur_id, type, annee)
|
||||
)
|
||||
```
|
||||
Le champ `type` est prévu pour réutiliser la table plus tard (ex. `'rendement_annuel'`) sans nouvelle migration — seul `'versement_annuel'` est utilisé actuellement.
|
||||
|
||||
### Backend
|
||||
`backend/src/routes/objectifs.js` (monté sur `/api/objectifs` dans `server.js`, entre `garantiesRouter` et `reinvestissementsRouter`) : `GET /` (filtres `?type=&annee=`, scope implicite = tous les investisseurs de `req.user.id` via jointure), `POST /` (upsert `ON CONFLICT(investisseur_id, type, annee)`), `DELETE /:id`. Pattern calqué sur `notation.js` (pas de middleware `requireInvestisseur`, ownership vérifiée par jointure SQL).
|
||||
|
||||
### Composant frontend réutilisable
|
||||
`frontend/src/components/SuiviObjectifs.jsx` — props : `rows` (mouvements bruts dépôts/retraits), `investisseurs`, `scopeInvestisseurIds` (tous les ids en vue "all", sinon `[activeId]`), `objectifType` (défaut `'versement_annuel'`), `title`. Gère lui-même le fetch/upsert/delete des objectifs. Tableau année par année : Année | Dépôts | Retraits | Différence | **Objectif annuel** | Écart, + **ligne Total** en `<tfoot>` (somme des colonnes sur les années affichées ; Objectif/Écart totaux affichent "—" si aucune année n'a d'objectif défini). Édition : clic sur la cellule Objectif annuel → un input par investisseur du scope (un seul si scope=1), Enregistrer fait un `Promise.all` de POST upsert (et DELETE si champ vidé). Bouton "+ Ajouter une année" pour anticiper une année future sans mouvement.
|
||||
|
||||
### Intégration (2 emplacements)
|
||||
1. `Plateformes.jsx` — onglet Dépôts/Retraits, section ajoutée sous le tableau "Mouvements de trésorerie" (`rows={allDepots}`).
|
||||
2. `DepotsRetraits.jsx` (page dédiée du menu latéral) — **onglet dédié "Vision annuelle"** dans `.dr-tabs`, positionné entre "Plateformes" et "Vision mensuelle" (`activeTab === 'vision-annuelle'`, `rows={allRows}`). Le KPI existant "Diff. Dépôts vs Retraits" est enrichi d'une ligne sous le chiffre : "Reste X € pour l'objectif AAAA" ou "+X € au-delà de l'objectif AAAA" (calcul indépendant du filtre plateforme, année = `drPlatYear` ou année en cours ; nécessite un fetch local `objectifsKpi` séparé de celui du composant, car le composant gère son propre state).
|
||||
|
||||
### Piège outillage — vérification syntaxique de fichiers JSX volumineux
|
||||
Le mount bash reste périmé (cf. sessions précédentes). Pour vérifier un **nouveau** composant JSX isolé (pas une édition dans un fichier existant de 1000+ lignes), la méthode fiable trouvée cette session : `npm init -y && npm install esbuild` dans `/tmp` (indépendant du node_modules Windows du projet, incompatible avec le sandbox Linux), copier le contenu exact du fichier via `cat > fichier << 'EOF'` puis `esbuild fichier.jsx --bundle --format=esm --jsx=automatic --external:react --outfile=...`. Pour une **édition ponctuelle** dans un gros fichier existant, la relecture `Read` ciblée des zones modifiées (comptage d'accolades/balises JSX) reste suffisante et plus rapide.
|
||||
|
||||
---
|
||||
|
||||
## Session 14 — Import IA (prompt + bugs), modale doublons, purge plateforme, job données incomplètes (2026-07-14)
|
||||
|
||||
### Prompt IA import Remboursements — itérations successives (`ImportsSection.jsx`, `buildReferenceSection`/`FIELD_HINTS_OVERRIDE`)
|
||||
1. **Plateforme unique** : si une seule plateforme existe, le prompt l'assigne directement à toutes les lignes sans poser de question (branche `plats.length === 1` dans `buildReferenceSection`).
|
||||
2. **Règle CAPITAL vs INTÉRÊTS** : distingue une ligne "remboursement mensualité" unique (montant mêlant capital+intérêts) via le ratio (prélèvements sociaux + IR) ÷ montant total : proche de 30 % → intérêts purs ; nettement inférieur → reconstitue `interets_bruts ≈ moyenne(prelev_sociaux/0.172, prelev_forfaitaire/0.128)` puis `capital = total − interets_bruts` ; aucun prélèvement adjacent → capital pur (échéance finale in fine/différé).
|
||||
3. **Identification cashback/bonus** (ajoutée suite à un cas réel : "Rémunération code cadeau") : reconnue par le **libellé** (mots-clés "cashback", "bonus", "prime", "code cadeau", "parrainage"...), jamais par l'absence de prélèvement seule (un remboursement de capital pur n'a lui non plus aucun prélèvement adjacent — ne pas confondre). Si le libellé ne référence aucun projet suivi (ex. parrainage global) → ligne exclue du JSON (le module d'import exige un `investissement_id`).
|
||||
4. **Règle critique présence systématique des champs** : imposer que les 5 champs (`capital`, `cashback`, `interets_bruts`, `prelev_sociaux`, `prelev_forfaitaire`) soient toujours explicitement présents sur CHAQUE ligne (valeur 0 si non applicable), jamais omis — corrige un vrai bug de détection (voir ci-dessous).
|
||||
5. Suppression de la consigne "génère aussi `net_recu`" (champ jamais lu par le backend, recalculé côté serveur depuis capital/cashback/intérêts/prélèvements) — source de confusion sans utilité.
|
||||
|
||||
### 🔴 Bug trouvé et corrigé — détection des colonnes basée uniquement sur la 1ère ligne du JSON
|
||||
- **Symptôme réel** : un remboursement de capital final (250 €) importé à 0,00 € partout dans l'app, alors que le JSON source contenait bien `"capital": 250`.
|
||||
- **Cause** : `POST /imports/preview` calcule `headers = Object.keys(rows[0])` — seule la première ligne du fichier sert à détecter les colonnes disponibles pour l'auto-mapping. Si un champ (ex. `capital`) est absent de la première ligne (fréquent avec les JSON générés par IA où seules certaines lignes ont telle ou telle info) mais présent plus loin, il n'est **jamais mappé** pour tout le fichier — donc toujours lu comme `0`, silencieusement, même sur les lignes où il est renseigné.
|
||||
- **Fix** : uniquement via le prompt (règle 4 ci-dessus, pas de changement de code) — imposer à l'IA génératrice de toujours inclure tous les champs optionnels sur chaque ligne, à 0 par défaut.
|
||||
|
||||
### 🔴 Bug trouvé et corrigé — champ `cashback` absent du schéma d'import Remboursements
|
||||
- **Symptôme réel** : une ligne de cashback (`"cashback": 2.5`) importée avec `cashback: 0,00 €` alors que tous les autres champs de mapping fonctionnaient.
|
||||
- **Cause, différente du bug précédent** : `MODULES.remboursements.optional` (`ImportsSection.jsx`) ne listait jamais `cashback` parmi les champs du module — ni pour l'auto-mapping (`runPreview`), ni pour le mapping manuel (`<select>`), ni pour la liste de champs générée dans le prompt IA (`buildFieldsList`). Le champ existe pourtant en base et dans le formulaire manuel de saisie. Bug préexistant, pas introduit cette session.
|
||||
- **Fix** : ajout de `'cashback'` à `MODULES.remboursements.optional`.
|
||||
- **Point d'attention** : les lignes déjà importées avec cashback perdu (0 au lieu du vrai montant) ne sont pas corrigées rétroactivement — la détection de doublon remboursements ne compare pas le cashback, donc un ré-import est soit ignoré comme doublon, soit crée une ligne en double si on force l'acceptation. Correction manuelle ligne par ligne recommandée dans ce cas.
|
||||
|
||||
### 🔴 Bug trouvé et corrigé — l'import de remboursements ne reproduit pas la logique de la saisie manuelle
|
||||
- **Symptôme réel** (2 captures d'écran comparées, dev vs prod) : un investissement soldé (capital intégralement remboursé) via **import** restait affiché `en_cours` avec un échéancier complet non réajusté (22 échéances futures inchangées), alors que le même remboursement saisi **manuellement** passait bien l'investissement à `rembourse` et tronquait l'échéancier aux échéances réellement dues (5 au lieu de 22).
|
||||
- **Cause** : `POST /remboursements` (saisie manuelle, `routes/remboursements.js`) appelle après chaque insertion `syncInvestissementStatut()` (passe `rembourse` si capital total remboursé ≥ montant investi + réinvestissements) et `adjustSimulForActuals()` (tronque/recalcule les échéances futures devenues caduques en cas de remboursement anticipé). `POST /imports/apply` (module `remboursements`) ne faisait qu'un `INSERT` brut en boucle, sans jamais appeler ces deux fonctions.
|
||||
- **Fix** : `syncInvestissementStatut` exportée depuis `remboursements.js` ; `imports.js` importe cette fonction + `adjustSimulForActuals` (déjà exportée de `schedule.js`) ; après le commit de la transaction d'import (module `remboursements`), boucle best-effort sur tous les `investissement_id` touchés par l'import pour appeler les deux fonctions — reproduit exactement le comportement de la saisie manuelle.
|
||||
- **Règle à retenir** : toute route d'import en masse qui insère directement dans une table déjà pourvue d'effets de bord post-insertion (recalcul de statut, régénération d'échéancier...) côté route manuelle doit rejouer ces mêmes effets de bord après le commit — ne pas se contenter du simple `INSERT`.
|
||||
|
||||
### Modale de revue des doublons à l'import (tous modules)
|
||||
- Nouvelle route `POST /imports/check-duplicates` (dry-run, même détection que `/apply` mais sans écriture) + `duplicateDecisions` accepté par `/apply` (`{ [rowNum]: 'accept'|'skip' }`).
|
||||
- Détection des doublons **internes au fichier** (pas seulement contre la base) via des `Map` en mémoire (`seenDepotsRetraits`, `seenInvestissements`, etc.) qui simulent l'effet séquentiel d'une transaction SQLite réelle (une ligne répétée plus loin dans le même fichier est comparée aux lignes déjà "vues", pas seulement à la base).
|
||||
- UI (`ImportsSection.jsx`) : ligne = case à cocher + titre "Doublon de données repéré en ligne X avec celles de la ligne Y (ou un enregistrement déjà en base)", détail replié par défaut (chevron), boutons "Cocher/Décocher tous les doublons".
|
||||
- **Pièges CSS rencontrés** (`styles.css` a des sélecteurs globaux `label`/`input` qui fuient sur du HTML brut) : `label { text-transform: uppercase; ...}` et `input,select,textarea { width:100%; padding:7px 10px; ...}` s'appliquent même à une checkbox de ligne — toujours réinitialiser explicitement en inline style (`textTransform:'none'`, `width:14, height:14, padding:0, flexShrink:0`), pattern déjà présent ailleurs (`.cat-select-item input[type="checkbox"]`). `.modal-overlay`/`.modal`/`.modal-header` étaient absentes de `styles.css` (seule `.modal-backdrop` existait, pour le composant `Modal.jsx` partagé) — ajoutées, corrige aussi rétroactivement les modales ad-hoc de `DataCleanupSection.jsx`.
|
||||
- Après import réussi : le textarea "Analyser les données" (IA) se vide et la page recharge automatiquement (message de résultat conservé via `sessionStorage` le temps du reload, réaffiché au montage).
|
||||
|
||||
### Job horaire — données essentielles manquantes sur les investissements
|
||||
- Nouveau `backend/src/jobs/checkDonneesIncompletes.js`, démarré dans `server.js` (`startCheckDonneesIncompletesJob`, pattern horaire identique à `autoTicketStatus.js`).
|
||||
- Vérifie sur chaque investissement `statut != 'cloture'` : `taux_interet`, `duree_mois`, `type_remb`, `date_premiere_echeance`.
|
||||
- **Anti-doublon de notification** : nouvelle colonne `investissements.donnees_incompletes_signature` (migration) stockant la liste triée des champs actuellement manquants. Notification renvoyée seulement si cette signature change (nouveau manque, ou toujours incomplet mais différemment) ; réinitialisée silencieusement (sans notif) quand tout redevient complet.
|
||||
- Même fonction rejouée en best-effort à la fin d'un import `investissements` réussi (`imports.js`, après le commit de la transaction).
|
||||
- Scope volontairement limité à ces 4 champs (portée assumée sans élargir sur le "etc." de la demande initiale).
|
||||
|
||||
### Suppression de données d'une plateforme (`DataCleanupSection.jsx` + `POST /plateformes/:id/purge-donnees`)
|
||||
- Scopes : toutes les données / dépôts-retraits / investissements (+ remboursements liés en cascade) / remboursements uniquement. La fiche plateforme n'est jamais supprimée.
|
||||
- **Confirmation par PIN à 6 chiffres** (remplace le "retapez le nom de la plateforme") : PIN aléatoire généré à l'ouverture de la modale (`Math.floor(100000 + Math.random()*900000)`), affiché en gros (monospace 30px, espacé, rouge) ; le payload envoyé à l'API contient toujours `confirmNom: purgePlat.nom` en interne (le PIN est une couche de confirmation UI uniquement, la vérification serveur par nom exact est inchangée).
|
||||
- **Sélecteur de plateforme corrigé pour le multi-détenteur** : deux plateformes de familles différentes peuvent porter le même nom (ex. deux comptes "Enky"). Fix en reprenant le pattern déjà établi ailleurs (`const multiDetenteurPlats = new Set(plats.map(p => p.investisseur_id)).size > 1`, suffixe `— {investisseur_nom}` dans les `<option>` et rappel dans le texte de la modale, uniquement si multi-détenteur) — cf. session 9 "Colonne Détenteur — masquée si mono-détenteur", même pattern à répliquer sur tout futur select de plateformes.
|
||||
|
||||
---
|
||||
|
||||
## Session 15 — Roadmap API publique + serveur MCP, phases 0 à 2 (2026-07-14)
|
||||
|
||||
Objectif produit (discuté et validé avant implémentation, cf. règle "poser des questions avant tâche complexe") : ouvrir une API en lecture seule + un serveur MCP local pour piloter l'app via un agent IA (Claude Desktop). Roadmap en 5 phases actée : 0) clés API, 1) API publique v1 + doc, 2) serveur MCP local (stdio), 3) outil `fetch_url` (non fait), 4) déploiement Docker prod, 5) MCP distant (non fait, plus tard). **Phases 0, 1, 2 et le déploiement en prod (phase 4) sont faites et validées par l'utilisateur en production.**
|
||||
|
||||
### Phase 0 — Clés API
|
||||
- Nouvelle table `api_keys` (migration `db/index.js`) : `user_id`, `investisseur_id` (une clé = un seul investisseur, jamais tous), `nom`, `key_prefix` (12 premiers car. affichés dans l'UI), `key_hash` (SHA-256, jamais le clair stocké), `scopes` (`'read'` seul utilisé), `created_at`, `last_used_at`, `revoked_at`.
|
||||
- Format de clé : `clk_live_` + 24 octets hex aléatoires. Générée par `backend/src/routes/apiKeys.js` (monté `/api/api-keys`, protégé par le JWT interne classique — c'est l'utilisateur qui gère ses clés via l'app, pas la clé elle-même qui authentifie ces routes).
|
||||
- Deux suppressions distinctes, bien différenciées côté UI (`MonCompte.jsx`, section "Clés API") :
|
||||
- **Révocation** (`DELETE /api-keys/:id`) : soft-delete, `revoked_at` set, la ligne reste visible pour historique/`last_used_at`.
|
||||
- **Suppression définitive** (`DELETE /api-keys/:id/purge`) : hard-delete. Icône poubelle par ligne — sur une clé **active**, ouvre une modale d'avertissement (irréversible, coupe l'accès immédiatement, suggère plutôt "Révoquer") ; sur une clé **déjà révoquée**, suppression directe sans confirmation (elle ne sert déjà plus à rien).
|
||||
- **Piège sécurité observé en session** : l'utilisateur a collé une clé API en clair dans le chat pour "tester". Réflexe correct appliqué : recommander révocation immédiate + regénération, rappeler qu'une clé ne doit transiter que entre l'app et le fichier de config local, jamais par un canal de conversation.
|
||||
|
||||
### Phase 1 — API publique `/api/v1` (lecture seule) + Swagger
|
||||
- Middleware `backend/src/middleware/apiKey.js` (`requireApiKey`) : lit `X-API-Key`, hash SHA-256, vérifie non révoquée, met à jour `last_used_at`, injecte `req.investisseurId`/`req.apiKeyId`/`req.apiScopes`. Distinct de `requireAuth` (JWT).
|
||||
- Routes dans `backend/src/routes/v1/` (agrégées par `v1/index.js`) : `GET /investisseur`, `GET /investissements` (+ filtre `?statut=`), `GET /investissements/:id` (avec ses remboursements), `GET /remboursements` (+ `?date_debut=&date_fin=`), `GET /depots-retraits`, `GET /dashboard` (KPIs simplifiés, requêtes SQL propres à v1, pas de réutilisation de la logique complexe de `routes/dashboard.js`). Toutes scopées strictement à `req.investisseurId` — pas de notion `scope=all` ici (une clé = un investisseur).
|
||||
- **Piège routing critique (corrigé)** : `server.js` a une route générique `app.use('/api', requireAuth, associationsInvRouter)` qui capte tout préfixe `/api/*`. Le montage de `/api/v1` et `/api/docs` doit impérativement se faire **avant** cette ligne (juste après `/api/auth`), sinon toute requête vers `/api/v1/*` est interceptée par le JWT interne et renvoie 401 "Missing or invalid Authorization header" au lieu d'atteindre `requireApiKey`.
|
||||
- Doc Swagger : `backend/src/swagger.js` (`swagger-jsdoc` + `swagger-ui-express`), annotations `@openapi` dans chaque route `v1/*.js`, servie sur `/api/docs` (public) + `/api/openapi.json`. Dépendances ajoutées à `backend/package.json`.
|
||||
- **Piège Windows** : `swagger-jsdoc` résout son option `apis` (glob) via une lib qui n'interprète pas les antislashs — `path.join(__dirname, 'routes/v1/*.js')` sous Windows produit des `\`, donc 0 route détectée ("No operations defined in spec!"). Fix : forcer des `/` (`p.split(path.sep).join('/')`) avant de passer le pattern à `swagger-jsdoc`.
|
||||
- **Piège annotations** : le `server.url` OpenAPI est déjà `/api/v1` — les chemins `@openapi` doivent être relatifs (`/dashboard`, pas `/v1/dashboard`), sinon Swagger UI construit des URLs doublées (`/api/v1/v1/dashboard`) qui retombent sur la route catch-all JWT (401 trompeur, à ne pas confondre avec un vrai problème de clé).
|
||||
- Testé en prod par l'utilisateur : création clé → doc Swagger (Authorize + Try it out) → 200 avec vraies données → révocation → 401 `"Invalid or revoked API key"`. Confirmé aussi compatible Power Query Excel (`Web.Contents` + header `X-API-Key`, pas de souci CORS car requête serveur-à-serveur).
|
||||
|
||||
### Phase 2 — Serveur MCP local (`mcp-server/`, nouveau dossier à la racine)
|
||||
- Package Node **séparé** du monorepo (son propre `package.json`), pur JS sans dépendance native (`@modelcontextprotocol/sdk` + `zod`) — contrairement au backend (`better-sqlite3`), donc **installable et testable tel quel dans le sandbox Linux**, aucun problème cross-plateforme.
|
||||
- `index.js` : `McpServer` + `StdioServerTransport`, 6 outils lecture seule préfixés `crowdlending_` (`get_investisseur`, `get_dashboard`, `list_investissements`, `get_investissement`, `list_remboursements`, `list_depots_retraits`), chacun appelle l'API v1 via `fetch` natif (Node ≥ 18) avec le header `X-API-Key`. Auth par `CROWDLENDING_API_KEY` (obligatoire, `process.exit(1)` sinon) + `CROWDLENDING_API_URL` (défaut `http://localhost:4000/api/v1`).
|
||||
- **Règle stdio impérative** : ne jamais `console.log` dans ce process (stdout = canal protocole JSON-RPC) — uniquement `console.error` pour les diagnostics. Vérifié en session (stdout capturé vide, stderr contient les logs).
|
||||
- **Distinction dev/prod pour connexions simultanées** : `CROWDLENDING_LABEL` (env var facultative) préfixe le nom du serveur MCP et ajoute `[DEV]`/`[PROD]` au titre + la source (URL API) en fin de description de chaque outil. Pas de collision technique possible entre deux instances connectées en même temps à Claude Desktop même si les noms d'outils sont identiques : le client namespace déjà par clé du bloc `mcpServers` (comme observé dans ce contexte agent : `mcp__<serveur>__<outil>`). Le `LABEL` sert uniquement à la lisibilité humaine/agent.
|
||||
- UI `MonCompte.jsx`, section "Serveur MCP" (nav `id: 'mcp'`) : guide pas-à-pas (créer une clé dédiée → renseigner chemin/URL/clé → JSON généré à copier → redémarrer Claude Desktop → vérifier). Le JSON est composé **côté client uniquement** — la clé saisie dans le champ n'est jamais envoyée au backend, juste utilisée pour l'aperçu affiché.
|
||||
- **Détection automatique dev/prod par URL** (`detectLabelFromUrl`) : hostname `localhost`/`127.0.0.1`/`.local` ou contenant "dev" → `DEV`, sinon `PROD` ; badge coloré affiché, case à cocher "Forcer manuellement" pour les cas ambigus (ex. domaine de test sans "dev" dans le nom). `guessMcpApiUrl()` distingue déjà dev (`:5173` → viser directement le backend `:4000`, le process Node MCP ne passe pas par le proxy Vite) vs prod (même origine que le frontend, nginx proxy `/api`).
|
||||
- `README.md` dédié dans `mcp-server/` : install, config Claude Desktop, double config dev+prod, test via MCP Inspector, tableau des 6 outils, dépannage.
|
||||
|
||||
### Piège outillage — cache bash figé sur un fichier précis
|
||||
Le mount bash (lecture de `C:\dev\crowdlending-app` depuis le sandbox Linux) a servi une version **figée à la toute première écriture** de `mcp-server/index.js` (même taille en octets, même `mtime`) malgré plusieurs `Edit` puis un `Write` complet ultérieurs — contrairement au comportement habituel de simple lag résolu par un `sleep`. Confirmé via `stat` (mtime figé). Contournement qui a fonctionné : écrire un fichier de contenu équivalent **directement via bash** (heredoc, sans passer par le mount Windows) dans `/tmp`, et valider la syntaxe/le comportement dessus — le `Read` tool (accès direct au filesystem Windows) reste la seule source fiable pour le contenu réel du fichier concerné.
|
||||
|
||||
### Reste à faire (roadmap)
|
||||
- Phase 3 : outil MCP `fetch_url` (extraction structurée d'une page plateforme pour pré-remplir un investissement, sans écriture automatique).
|
||||
- Phase 5 : variante MCP distante (HTTP/SSE, OAuth) dans le `docker-compose.yml` de prod — plus tard, une fois l'usage local stabilisé.
|
||||
|
||||
Generated
+477
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
+253
-14
@@ -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);
|
||||
@@ -2062,4 +2137,168 @@ console.log('[DB] Migrations 2FA OK');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Migration : exclusions de tags hérités du référentiel sur plateformes ──
|
||||
// Permet à l'utilisateur de retirer une catégorie/secteur hérité du référentiel
|
||||
// sans que la fusion d'affichage ou un futur push référentiel ne la réinjecte.
|
||||
{
|
||||
const platColsExcl = db.prepare('PRAGMA table_info(plateformes)').all().map(c => c.name);
|
||||
if (!platColsExcl.includes('excluded_categories_inv_ids')) {
|
||||
db.exec("ALTER TABLE plateformes ADD COLUMN excluded_categories_inv_ids TEXT NOT NULL DEFAULT '[]'");
|
||||
}
|
||||
if (!platColsExcl.includes('excluded_secteurs_inv_ids')) {
|
||||
db.exec("ALTER TABLE plateformes ADD COLUMN excluded_secteurs_inv_ids TEXT NOT NULL DEFAULT '[]'");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Migration : compteur de doublons ignorés sur imports ──────────────────
|
||||
// Distingue les lignes ignorées car identiques à une ligne déjà en base
|
||||
// (rows_duplicates) des lignes ignorées pour une autre raison (rows_skipped
|
||||
// reste le total ; rows_duplicates est un sous-ensemble informatif).
|
||||
{
|
||||
const importsCols = db.prepare('PRAGMA table_info(imports)').all().map(c => c.name);
|
||||
if (!importsCols.includes('rows_duplicates')) {
|
||||
db.exec('ALTER TABLE imports ADD COLUMN rows_duplicates INTEGER NOT NULL DEFAULT 0');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Migration : plateforme_id en CASCADE (au lieu de RESTRICT) sur
|
||||
// depots_retraits et investissements ────────────────────────────────────
|
||||
// RESTRICT empêchait la suppression complète d'un compte (DELETE FROM users) :
|
||||
// la cascade users→plateformes (user_id CASCADE) et users→investisseurs→
|
||||
// depots_retraits/investissements (CASCADE) sont deux branches indépendantes
|
||||
// de l'arbre de suppression ; SQLite peut supprimer une plateforme avant les
|
||||
// lignes qui la référencent encore sur l'autre branche, ce qui déclenche le
|
||||
// RESTRICT (erreur SQLITE_CONSTRAINT_TRIGGER). La protection "impossible de
|
||||
// supprimer une plateforme qui a encore des données" est déplacée dans la
|
||||
// route DELETE /api/plateformes/:id (vérification explicite avec message clair).
|
||||
{
|
||||
const fixPlateformeCascade = (tableName, indexStatements) => {
|
||||
const row = db.prepare(
|
||||
`SELECT sql FROM sqlite_master WHERE type='table' AND name=?`
|
||||
).get(tableName);
|
||||
if (!row || !/REFERENCES\s+plateformes\(id\)\s+ON DELETE RESTRICT/i.test(row.sql)) return;
|
||||
|
||||
const tempName = `__repair_${tableName}`;
|
||||
const nameRe = new RegExp(
|
||||
`CREATE TABLE\\s+(?:IF NOT EXISTS\\s+)?["'\`\\[]?${tableName}["'\`\\]]?`, 'i'
|
||||
);
|
||||
if (!nameRe.test(row.sql)) {
|
||||
console.error(`[DB] migration plateforme_id CASCADE : nom de table non reconnu dans le DDL de "${tableName}", migration ignorée.`);
|
||||
return;
|
||||
}
|
||||
const fixedDdl = row.sql
|
||||
.replace(nameRe, `CREATE TABLE "${tempName}"`)
|
||||
.replace(/REFERENCES\s+plateformes\(id\)\s+ON DELETE RESTRICT/i, 'REFERENCES plateformes(id) ON DELETE CASCADE');
|
||||
|
||||
const colDefs = db.prepare(`PRAGMA table_info("${tableName}")`).all();
|
||||
const colNames = colDefs.map(c => `"${c.name}"`).join(', ');
|
||||
|
||||
const idxs = db.prepare(
|
||||
`SELECT name FROM sqlite_master WHERE type='index' AND tbl_name=? AND sql IS NOT NULL`
|
||||
).all(tableName);
|
||||
|
||||
db.exec('PRAGMA foreign_keys = OFF');
|
||||
db.exec(`DROP TABLE IF EXISTS "${tempName}"`);
|
||||
db.exec(fixedDdl);
|
||||
db.exec(`INSERT INTO "${tempName}" (${colNames}) SELECT ${colNames} FROM "${tableName}"`);
|
||||
for (const idx of idxs) db.exec(`DROP INDEX IF EXISTS "${idx.name}"`);
|
||||
db.exec(`DROP TABLE "${tableName}"`);
|
||||
db.exec('PRAGMA legacy_alter_table = ON');
|
||||
db.exec(`ALTER TABLE "${tempName}" RENAME TO "${tableName}"`);
|
||||
db.exec('PRAGMA legacy_alter_table = OFF');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
|
||||
for (const stmt of indexStatements) db.exec(stmt);
|
||||
console.log(`[DB] migration : plateforme_id passé en ON DELETE CASCADE sur "${tableName}".`);
|
||||
};
|
||||
|
||||
fixPlateformeCascade('depots_retraits', [
|
||||
'CREATE INDEX IF NOT EXISTS idx_depret_inv ON depots_retraits(investisseur_id)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_depret_plat ON depots_retraits(plateforme_id)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_depret_date ON depots_retraits(date_operation)',
|
||||
]);
|
||||
|
||||
fixPlateformeCascade('investissements', [
|
||||
'CREATE INDEX IF NOT EXISTS idx_inv_inv ON investissements(investisseur_id)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_inv_plat ON investissements(plateforme_id)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_inv_statut ON investissements(statut)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_inv_date ON investissements(date_souscription)',
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Migration : table objectifs ──────────────────────────────────────────
|
||||
// Objectifs annuels par investisseur. `type` permet de réutiliser la table
|
||||
// pour d'autres natures d'objectifs plus tard (ex: 'rendement_annuel') —
|
||||
// pour l'instant seul 'versement_annuel' (objectif de dépôts nets) est utilisé.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS objectifs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
investisseur_id INTEGER NOT NULL REFERENCES investisseurs(id) ON DELETE CASCADE,
|
||||
type TEXT NOT NULL DEFAULT 'versement_annuel',
|
||||
annee INTEGER NOT NULL,
|
||||
montant REAL NOT NULL,
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(investisseur_id, type, annee)
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_objectifs_investisseur ON objectifs(investisseur_id, type, annee)');
|
||||
|
||||
// ── Migration : suivi anti-doublon des notifications "données incomplètes" ──
|
||||
// Stocke la signature (liste triée des champs essentiels manquants) au moment
|
||||
// de la dernière notification envoyée pour cet investissement, afin que le job
|
||||
// checkDonneesIncompletes ne notifie à nouveau que si ce manque a changé
|
||||
// (nouveau champ manquant, champ comblé, ou tout comblé → signature NULL).
|
||||
{
|
||||
const cols = db.prepare('PRAGMA table_info(investissements)').all().map(c => c.name);
|
||||
if (!cols.includes('donnees_incompletes_signature')) {
|
||||
db.exec('ALTER TABLE investissements ADD COLUMN donnees_incompletes_signature TEXT');
|
||||
console.log('[DB] investissements: colonne donnees_incompletes_signature ajoutée');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Migration : table api_keys ───────────────────────────────────────────
|
||||
// Clés API générées par l'utilisateur pour un accès programmatique/MCP.
|
||||
// Chaque clé est scopée à un investisseur précis (usage single-user
|
||||
// multi-investisseur — pas de notion multi-tenant ici). Seul le hash SHA-256
|
||||
// est stocké ; la valeur en clair n'est montrée qu'une fois à la création.
|
||||
// `key_prefix` (8 premiers caractères) permet d'identifier une clé dans
|
||||
// l'UI sans jamais réafficher la valeur complète.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
investisseur_id INTEGER NOT NULL REFERENCES investisseurs(id) ON DELETE CASCADE,
|
||||
nom TEXT NOT NULL,
|
||||
key_prefix TEXT NOT NULL,
|
||||
key_hash TEXT NOT NULL UNIQUE,
|
||||
scopes TEXT NOT NULL DEFAULT 'read',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_used_at TEXT,
|
||||
revoked_at TEXT
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_api_keys_user ON api_keys(user_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_api_keys_inv ON api_keys(investisseur_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash)');
|
||||
|
||||
// ── Migration : clés API à scope "Famille et entreprises" ───────────────
|
||||
// Une clé peut désormais couvrir tous les investisseurs du foyer plutôt
|
||||
// qu'un seul (agrégation, en miroir du scope=all déjà utilisé par le
|
||||
// frontend JWT). `investisseur_id` reste NOT NULL pour ne pas toucher à la
|
||||
// contrainte existante : quand scope_all=1, la route de création force
|
||||
// investisseur_id à pointer vers l'investisseur principal (ancrage FK),
|
||||
// mais les routes /api/v1/* ignorent alors cette valeur au profit d'un
|
||||
// filtre "tous les investisseurs de ce user_id" — voir apiKey.js et
|
||||
// routes/v1/*.js. Seul le profil principal peut créer une clé scope_all=1
|
||||
// (enforcement côté serveur dans routes/apiKeys.js, pas seulement l'UI).
|
||||
{
|
||||
const cols = db.prepare('PRAGMA table_info(api_keys)').all().map(c => c.name);
|
||||
if (!cols.includes('scope_all')) {
|
||||
db.exec('ALTER TABLE api_keys ADD COLUMN scope_all INTEGER NOT NULL DEFAULT 0');
|
||||
console.log('[DB] api_keys: colonne scope_all ajoutée');
|
||||
}
|
||||
}
|
||||
|
||||
export default db;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
@@ -28,8 +28,7 @@ const router = Router();
|
||||
function checkPlatOwner(platId, userId) {
|
||||
const row = db.prepare(`
|
||||
SELECT p.id FROM plateformes p
|
||||
JOIN investisseurs i ON i.id = p.investisseur_id
|
||||
WHERE p.id = ? AND i.user_id = ?
|
||||
WHERE p.id = ? AND p.user_id = ?
|
||||
`).get(platId, userId);
|
||||
if (!row) throw new HttpError(404, 'Plateforme introuvable ou accès refusé.');
|
||||
return row;
|
||||
@@ -80,15 +79,16 @@ router.get('/plateformes/:id/categories-inv', (req, res, next) => {
|
||||
ORDER BY is_global DESC, c.nom
|
||||
`).all(req.params.id);
|
||||
|
||||
// Merge tags du référentiel avec is_inherited: 1
|
||||
const plat = db.prepare('SELECT referentiel_id FROM plateformes WHERE id = ?').get(req.params.id);
|
||||
// Merge tags du référentiel avec is_inherited: 1 (sauf ceux explicitement retirés par l'utilisateur)
|
||||
const plat = db.prepare('SELECT referentiel_id, excluded_categories_inv_ids FROM plateformes WHERE id = ?').get(req.params.id);
|
||||
if (plat?.referentiel_id) {
|
||||
const excludedIds = new Set(JSON.parse(plat.excluded_categories_inv_ids || '[]'));
|
||||
const refCats = db.prepare(`
|
||||
SELECT c.id, c.nom, CASE WHEN c.user_id IS NULL THEN 1 ELSE 0 END AS is_global
|
||||
FROM referentiel_categories_inv rc
|
||||
JOIN categories_inv c ON c.id = rc.categorie_id
|
||||
WHERE rc.referentiel_id = ?
|
||||
`).all(plat.referentiel_id);
|
||||
`).all(plat.referentiel_id).filter(rc => !excludedIds.has(rc.id));
|
||||
const refIdSet = new Set(refCats.map(r => r.id));
|
||||
for (const row of rows) if (refIdSet.has(row.id)) row.is_inherited = 1;
|
||||
const ownIds = new Set(rows.map(r => r.id));
|
||||
@@ -108,20 +108,25 @@ router.put('/plateformes/:id/categories-inv', (req, res, next) => {
|
||||
checkPlatOwner(req.params.id, userId);
|
||||
checkCatIds(ids, userId);
|
||||
|
||||
// Toujours inclure les tags hérités du référentiel
|
||||
// L'utilisateur peut retirer un tag hérité du référentiel : on respecte exactement
|
||||
// sa sélection, et on mémorise les tags référentiel explicitement exclus pour que
|
||||
// la fusion d'affichage (GET) et un futur push référentiel ne les réinjectent pas.
|
||||
const plat = db.prepare('SELECT referentiel_id FROM plateformes WHERE id = ?').get(req.params.id);
|
||||
let allIds = [...ids];
|
||||
const idSet = new Set(ids);
|
||||
let excludedIds = [];
|
||||
if (plat?.referentiel_id) {
|
||||
const refCatIds = db.prepare('SELECT categorie_id FROM referentiel_categories_inv WHERE referentiel_id = ?')
|
||||
.all(plat.referentiel_id).map(r => r.categorie_id);
|
||||
allIds = [...new Set([...refCatIds, ...ids])];
|
||||
excludedIds = refCatIds.filter(id => !idSet.has(id));
|
||||
}
|
||||
|
||||
db.transaction(() => {
|
||||
db.prepare('DELETE FROM plateforme_categories_inv WHERE plateforme_id = ?').run(req.params.id);
|
||||
const ins = db.prepare('INSERT INTO plateforme_categories_inv (plateforme_id, categorie_id) VALUES (?, ?)');
|
||||
for (const id of allIds) ins.run(req.params.id, id);
|
||||
syncInvestissementsCategories(req.params.id, allIds);
|
||||
for (const id of ids) ins.run(req.params.id, id);
|
||||
db.prepare('UPDATE plateformes SET excluded_categories_inv_ids = ? WHERE id = ?')
|
||||
.run(JSON.stringify(excludedIds), req.params.id);
|
||||
syncInvestissementsCategories(req.params.id, ids);
|
||||
})();
|
||||
res.json({ ok: true });
|
||||
} catch (e) { next(e); }
|
||||
@@ -140,14 +145,15 @@ router.get('/plateformes/:id/secteurs-inv', (req, res, next) => {
|
||||
ORDER BY is_global DESC, s.nom
|
||||
`).all(req.params.id);
|
||||
|
||||
const plat = db.prepare('SELECT referentiel_id FROM plateformes WHERE id = ?').get(req.params.id);
|
||||
const plat = db.prepare('SELECT referentiel_id, excluded_secteurs_inv_ids FROM plateformes WHERE id = ?').get(req.params.id);
|
||||
if (plat?.referentiel_id) {
|
||||
const excludedIds = new Set(JSON.parse(plat.excluded_secteurs_inv_ids || '[]'));
|
||||
const refSects = db.prepare(`
|
||||
SELECT s.id, s.nom, CASE WHEN s.user_id IS NULL THEN 1 ELSE 0 END AS is_global
|
||||
FROM referentiel_secteurs_inv rs
|
||||
JOIN secteurs_inv s ON s.id = rs.secteur_id
|
||||
WHERE rs.referentiel_id = ?
|
||||
`).all(plat.referentiel_id);
|
||||
`).all(plat.referentiel_id).filter(rs => !excludedIds.has(rs.id));
|
||||
const refIdSet = new Set(refSects.map(r => r.id));
|
||||
for (const row of rows) if (refIdSet.has(row.id)) row.is_inherited = 1;
|
||||
const ownIds = new Set(rows.map(r => r.id));
|
||||
@@ -167,19 +173,24 @@ router.put('/plateformes/:id/secteurs-inv', (req, res, next) => {
|
||||
checkPlatOwner(req.params.id, userId);
|
||||
checkSectIds(ids, userId);
|
||||
|
||||
// Même logique que pour les catégories : on respecte la sélection de l'utilisateur
|
||||
// et on mémorise les secteurs référentiel explicitement exclus.
|
||||
const plat = db.prepare('SELECT referentiel_id FROM plateformes WHERE id = ?').get(req.params.id);
|
||||
let allIds = [...ids];
|
||||
const idSet = new Set(ids);
|
||||
let excludedIds = [];
|
||||
if (plat?.referentiel_id) {
|
||||
const refSectIds = db.prepare('SELECT secteur_id FROM referentiel_secteurs_inv WHERE referentiel_id = ?')
|
||||
.all(plat.referentiel_id).map(r => r.secteur_id);
|
||||
allIds = [...new Set([...refSectIds, ...ids])];
|
||||
excludedIds = refSectIds.filter(id => !idSet.has(id));
|
||||
}
|
||||
|
||||
db.transaction(() => {
|
||||
db.prepare('DELETE FROM plateforme_secteurs_inv WHERE plateforme_id = ?').run(req.params.id);
|
||||
const ins = db.prepare('INSERT INTO plateforme_secteurs_inv (plateforme_id, secteur_id) VALUES (?, ?)');
|
||||
for (const id of allIds) ins.run(req.params.id, id);
|
||||
syncInvestissementsSecteurs(req.params.id, allIds);
|
||||
for (const id of ids) ins.run(req.params.id, id);
|
||||
db.prepare('UPDATE plateformes SET excluded_secteurs_inv_ids = ? WHERE id = ?')
|
||||
.run(JSON.stringify(excludedIds), req.params.id);
|
||||
syncInvestissementsSecteurs(req.params.id, ids);
|
||||
})();
|
||||
res.json({ ok: true });
|
||||
} catch (e) { next(e); }
|
||||
|
||||
@@ -13,6 +13,24 @@ function purgeOldLogs() {
|
||||
db.prepare("DELETE FROM audit_logs WHERE created_at < datetime('now', '-30 days')").run();
|
||||
}
|
||||
|
||||
// ── Classification succès / avertissement / échec par action ──────────────
|
||||
// IMPORTANT : garder cette liste synchronisée avec ACTION_STATUS dans
|
||||
// frontend/src/pages/admin/AuditLogsSection.jsx (icônes + libellés).
|
||||
const STATUS_ACTIONS = {
|
||||
success: ['login_success', 'login_2fa_success', 'user_registered', 'user_created', 'email_verified_admin', 'invitation_accepted', 'invitation_sent', '2fa_enabled'],
|
||||
warning: ['role_changed', 'status_changed', '2fa_disabled', 'user_deleted', 'account_self_deleted'],
|
||||
failure: ['login_failed'],
|
||||
};
|
||||
const STATUS_CASE_SQL = `
|
||||
CASE
|
||||
WHEN al.action IN (${STATUS_ACTIONS.success.map(() => '?').join(',')}) THEN 'success'
|
||||
WHEN al.action IN (${STATUS_ACTIONS.warning.map(() => '?').join(',')}) THEN 'warning'
|
||||
WHEN al.action IN (${STATUS_ACTIONS.failure.map(() => '?').join(',')}) THEN 'failure'
|
||||
ELSE NULL
|
||||
END
|
||||
`;
|
||||
const STATUS_CASE_PARAMS = [...STATUS_ACTIONS.success, ...STATUS_ACTIONS.warning, ...STATUS_ACTIONS.failure];
|
||||
|
||||
// ── GET / — liste paginée avec filtres ────────────────────────────────────
|
||||
router.get('/', (req, res, next) => {
|
||||
try {
|
||||
@@ -22,37 +40,48 @@ router.get('/', (req, res, next) => {
|
||||
const limit = Math.min(Number(req.query.limit) || 50, 200);
|
||||
const offset = (page - 1) * limit;
|
||||
const category = req.query.category || null;
|
||||
const status = req.query.status || null; // 'success' | 'warning' | 'failure'
|
||||
const search = req.query.search || null; // filtre sur email acteur ou cible
|
||||
const dateFrom = req.query.dateFrom || null;
|
||||
const dateTo = req.query.dateTo || null;
|
||||
const userId = req.query.userId ? Number(req.query.userId) : null;
|
||||
|
||||
const conditions = [];
|
||||
const params = [];
|
||||
// Conditions communes (hors statut) — réutilisées pour les compteurs par statut,
|
||||
// qui doivent rester visibles/à jour même quand un statut est déjà sélectionné.
|
||||
const baseConditions = [];
|
||||
const baseParams = [];
|
||||
|
||||
if (category) {
|
||||
conditions.push('al.category = ?');
|
||||
params.push(category);
|
||||
baseConditions.push('al.category = ?');
|
||||
baseParams.push(category);
|
||||
}
|
||||
if (userId) {
|
||||
conditions.push('(al.actor_id = ? OR al.target_user_id = ?)');
|
||||
params.push(userId, userId);
|
||||
baseConditions.push('(al.actor_id = ? OR al.target_user_id = ?)');
|
||||
baseParams.push(userId, userId);
|
||||
}
|
||||
if (search) {
|
||||
conditions.push('(actor.email LIKE ? OR target.email LIKE ? OR actor.display_name LIKE ? OR target.display_name LIKE ?)');
|
||||
baseConditions.push('(actor.email LIKE ? OR target.email LIKE ? OR actor.display_name LIKE ? OR target.display_name LIKE ?)');
|
||||
const like = `%${search}%`;
|
||||
params.push(like, like, like, like);
|
||||
baseParams.push(like, like, like, like);
|
||||
}
|
||||
if (dateFrom) {
|
||||
conditions.push('al.created_at >= ?');
|
||||
params.push(dateFrom);
|
||||
baseConditions.push('al.created_at >= ?');
|
||||
baseParams.push(dateFrom);
|
||||
}
|
||||
if (dateTo) {
|
||||
conditions.push('al.created_at <= ?');
|
||||
params.push(dateTo + ' 23:59:59');
|
||||
baseConditions.push('al.created_at <= ?');
|
||||
baseParams.push(dateTo + ' 23:59:59');
|
||||
}
|
||||
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const conditions = [...baseConditions];
|
||||
const params = [...baseParams];
|
||||
if (status) {
|
||||
conditions.push(`(${STATUS_CASE_SQL}) = ?`);
|
||||
params.push(...STATUS_CASE_PARAMS, status);
|
||||
}
|
||||
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const baseWhere = baseConditions.length ? `WHERE ${baseConditions.join(' AND ')}` : '';
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT
|
||||
@@ -85,13 +114,41 @@ router.get('/', (req, res, next) => {
|
||||
${where}
|
||||
`).get(...params).n;
|
||||
|
||||
// Parser les details JSON
|
||||
const parsed = rows.map(r => ({
|
||||
...r,
|
||||
details: r.details ? (() => { try { return JSON.parse(r.details); } catch { return r.details; } })() : null,
|
||||
}));
|
||||
// Compteurs par statut, calculés avec les mêmes filtres (catégorie/recherche/dates)
|
||||
// mais SANS le filtre statut lui-même, pour permettre de basculer entre statuts.
|
||||
const statusRows = db.prepare(`
|
||||
SELECT (${STATUS_CASE_SQL}) AS ev_status, COUNT(*) AS n
|
||||
FROM audit_logs al
|
||||
LEFT JOIN users actor ON actor.id = al.actor_id
|
||||
LEFT JOIN users target ON target.id = al.target_user_id
|
||||
${baseWhere}
|
||||
GROUP BY ev_status
|
||||
`).all(...STATUS_CASE_PARAMS, ...baseParams);
|
||||
|
||||
res.json({ total, page, limit, rows: parsed });
|
||||
const statusCounts = { success: 0, warning: 0, failure: 0 };
|
||||
for (const r of statusRows) {
|
||||
if (r.ev_status && statusCounts[r.ev_status] !== undefined) statusCounts[r.ev_status] = r.n;
|
||||
}
|
||||
|
||||
// Parser les details JSON
|
||||
const parsed = rows.map(r => {
|
||||
const details = r.details ? (() => { try { return JSON.parse(r.details); } catch { return r.details; } })() : null;
|
||||
// Repli sur les infos figées dans "details" quand l'utilisateur (acteur et/ou cible)
|
||||
// a depuis été supprimé — son FK passe à NULL (ON DELETE SET NULL) mais l'email/nom
|
||||
// saisis au moment de l'action restent lisibles pour les admins.
|
||||
const fallbackName = details && typeof details === 'object' ? (details.display_name || null) : null;
|
||||
const fallbackEmail = details && typeof details === 'object' ? (details.email || null) : null;
|
||||
return {
|
||||
...r,
|
||||
details,
|
||||
actor_name: r.actor_name ?? (r.actor_id == null ? fallbackName : null),
|
||||
actor_email: r.actor_email ?? (r.actor_id == null ? fallbackEmail : null),
|
||||
target_name: r.target_name ?? (r.target_id == null ? fallbackName : null),
|
||||
target_email:r.target_email?? (r.target_id == null ? fallbackEmail: null),
|
||||
};
|
||||
});
|
||||
|
||||
res.json({ total, page, limit, rows: parsed, statusCounts });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
|
||||
@@ -252,6 +252,76 @@ router.put('/me', requireAuth, async (req, res, next) => {
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ── Suppression définitive du compte (self-service) ────────────────────────
|
||||
const DeleteMeSchema = z.object({
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
router.delete('/me', requireAuth, (req, res, next) => {
|
||||
try {
|
||||
const { password } = DeleteMeSchema.parse(req.body);
|
||||
|
||||
const user = db
|
||||
.prepare('SELECT id, email, display_name, role, password_hash FROM users WHERE id = ?')
|
||||
.get(req.user.id);
|
||||
if (!user) throw new HttpError(404, 'Utilisateur introuvable');
|
||||
|
||||
const ok = bcrypt.compareSync(password, user.password_hash);
|
||||
if (!ok) throw new HttpError(401, 'Mot de passe incorrect.');
|
||||
|
||||
// Empêche de se retrouver sans aucun administrateur sur l'application
|
||||
if (user.role === 'admin') {
|
||||
const { n: adminCount } = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin'").get();
|
||||
if (adminCount <= 1) {
|
||||
throw new HttpError(400, "Vous êtes le seul administrateur de l'application. Promouvez un autre compte en administrateur avant de supprimer le vôtre.");
|
||||
}
|
||||
}
|
||||
|
||||
// Log AVANT suppression : target_user_id/actor_id passeront à NULL après le DELETE
|
||||
// (FK ON DELETE SET NULL), mais les informations restent lisibles dans "details".
|
||||
audit(req, {
|
||||
action: 'account_self_deleted',
|
||||
category: 'account',
|
||||
actorId: user.id,
|
||||
targetUserId: user.id,
|
||||
details: {
|
||||
email: user.email,
|
||||
display_name: user.display_name,
|
||||
role: user.role,
|
||||
initiated_by: 'self',
|
||||
note: "Suppression de compte initiée par l'utilisateur lui-même depuis Mon compte.",
|
||||
},
|
||||
});
|
||||
|
||||
// Notifier les autres administrateurs
|
||||
const otherAdmins = db.prepare("SELECT id FROM users WHERE role = 'admin' AND id != ?").all(user.id);
|
||||
if (otherAdmins.length > 0) {
|
||||
const insertNotif = db.prepare(
|
||||
'INSERT INTO notifications (user_id, type, title, body, link) VALUES (?, ?, ?, ?, ?)'
|
||||
);
|
||||
const notifyTx = db.transaction((rows) => {
|
||||
for (const admin of rows) {
|
||||
insertNotif.run(
|
||||
admin.id,
|
||||
'security',
|
||||
'Suppression de compte utilisateur',
|
||||
`${user.display_name || user.email} (${user.email}) a supprimé définitivement son propre compte.`,
|
||||
'/admin?section=audit-logs',
|
||||
);
|
||||
}
|
||||
});
|
||||
notifyTx(otherAdmins);
|
||||
}
|
||||
|
||||
// Suppression définitive — cascade en base sur toutes les données liées
|
||||
// (investisseurs, plateformes, investissements, remboursements, comptes,
|
||||
// préférences, notifications, tickets, appareils de confiance, etc.)
|
||||
db.prepare('DELETE FROM users WHERE id = ?').run(user.id);
|
||||
|
||||
res.status(204).end();
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ── Vérification d'adresse email ──────────────────────────────────────────
|
||||
router.get('/verify-email', (req, res, next) => {
|
||||
try {
|
||||
|
||||
@@ -22,8 +22,7 @@ router.get('/', (req, res) => {
|
||||
(SELECT COUNT(*) FROM plateforme_categories_inv pc WHERE pc.categorie_id = c.id
|
||||
AND pc.plateforme_id IN (
|
||||
SELECT p.id FROM plateformes p
|
||||
JOIN investisseurs i ON i.id = p.investisseur_id
|
||||
WHERE i.user_id = ?
|
||||
WHERE p.user_id = ?
|
||||
)
|
||||
) AS nb_plateformes,
|
||||
(SELECT COUNT(*) FROM investissement_categories_inv ic WHERE ic.categorie_id = c.id
|
||||
|
||||
@@ -29,10 +29,11 @@ function ensureRow() {
|
||||
router.get('/', (_req, res, next) => {
|
||||
try {
|
||||
ensureRow();
|
||||
const row = db.prepare('SELECT app_name, app_url, allow_registration, min_password_length FROM smtp_config WHERE id = 1').get();
|
||||
const row = db.prepare('SELECT app_name, app_url, mcp_url, allow_registration, min_password_length FROM smtp_config WHERE id = 1').get();
|
||||
res.json({
|
||||
appName: row.app_name || 'Crowdlending Tracker',
|
||||
appUrl: row.app_url || '',
|
||||
mcpUrl: row.mcp_url || '',
|
||||
allowRegistration: row.allow_registration !== 0,
|
||||
minPasswordLength: row.min_password_length || 8,
|
||||
});
|
||||
@@ -42,6 +43,7 @@ router.get('/', (_req, res, next) => {
|
||||
const PatchSchema = z.object({
|
||||
appName: z.string().min(1).max(100).optional(),
|
||||
appUrl: z.string().max(500).optional(),
|
||||
mcpUrl: z.string().max(500).optional(),
|
||||
allowRegistration: z.boolean().optional(),
|
||||
minPasswordLength: z.number().int().min(6).max(64).optional(),
|
||||
});
|
||||
@@ -50,18 +52,20 @@ router.patch('/', (req, res, next) => {
|
||||
try {
|
||||
ensureRow();
|
||||
const body = PatchSchema.parse(req.body);
|
||||
const row = db.prepare('SELECT app_name, app_url, allow_registration, min_password_length FROM smtp_config WHERE id = 1').get();
|
||||
const row = db.prepare('SELECT app_name, app_url, mcp_url, allow_registration, min_password_length FROM smtp_config WHERE id = 1').get();
|
||||
|
||||
db.prepare(`
|
||||
UPDATE smtp_config SET
|
||||
app_name = ?,
|
||||
app_url = ?,
|
||||
mcp_url = ?,
|
||||
allow_registration = ?,
|
||||
min_password_length = ?
|
||||
WHERE id = 1
|
||||
`).run(
|
||||
body.appName !== undefined ? body.appName : (row.app_name || 'Crowdlending Tracker'),
|
||||
body.appUrl !== undefined ? body.appUrl : (row.app_url || ''),
|
||||
body.mcpUrl !== undefined ? body.mcpUrl : (row.mcp_url || ''),
|
||||
body.allowRegistration !== undefined ? (body.allowRegistration ? 1 : 0) : (row.allow_registration !== 0 ? 1 : 0),
|
||||
body.minPasswordLength !== undefined ? body.minPasswordLength : (row.min_password_length || 8),
|
||||
);
|
||||
|
||||
+456
-41
@@ -6,7 +6,10 @@ import fs from 'node:fs';
|
||||
import db from '../db/index.js';
|
||||
import { HttpError } from '../middleware/errorHandler.js';
|
||||
import { requireInvestisseur } from '../middleware/investisseurScope.js';
|
||||
import { generateSimul, generateSimulWithReinvestissements } from '../utils/schedule.js';
|
||||
import { generateSimul, generateSimulWithReinvestissements, adjustSimulForActuals } from '../utils/schedule.js';
|
||||
import { recordHistory, detectChangements, detectTypeEvenement } from './investissements.js';
|
||||
import { checkDonneesIncompletes } from '../jobs/checkDonneesIncompletes.js';
|
||||
import { syncInvestissementStatut } from './remboursements.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -92,7 +95,14 @@ router.post('/preview', upload.single('file'), (req, res, next) => {
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
router.post('/apply', (req, res, next) => {
|
||||
/**
|
||||
* POST /api/imports/check-duplicates
|
||||
* Même entrée que /apply (tempId, module, mapping, defaults) mais AUCUNE écriture en base :
|
||||
* rejoue la détection anti-doublon module par module et renvoie, pour chaque ligne concernée,
|
||||
* les valeurs de la ligne importée en regard de l'enregistrement existant en base, afin que
|
||||
* l'utilisateur puisse décider ligne par ligne (modale de doublons) avant l'import réel.
|
||||
*/
|
||||
router.post('/check-duplicates', (req, res, next) => {
|
||||
try {
|
||||
const { tempId, module, mapping, defaults = {} } = req.body || {};
|
||||
if (!tempId || !module || !mapping) {
|
||||
@@ -101,6 +111,190 @@ router.post('/apply', (req, res, next) => {
|
||||
const def = MODULES[module];
|
||||
if (!def) throw new HttpError(400, 'Unknown module');
|
||||
|
||||
if (INVESTISSEUR_SCOPED.includes(module)) {
|
||||
requireInvestisseur(req, res, () => {});
|
||||
}
|
||||
|
||||
const tempPath = path.join(UPLOAD_DIR, tempId);
|
||||
if (!fs.existsSync(tempPath)) throw new HttpError(404, 'Uploaded file expired');
|
||||
|
||||
const origName = req.body.originalFilename || '';
|
||||
const { rows } = parseFile(tempPath, origName || 'file.xlsx');
|
||||
|
||||
let platIdSet = new Set(), platNameMap = new Map(), platNomById = new Map();
|
||||
if (module === 'depots_retraits' || module === 'investissements') {
|
||||
const platRows = db.prepare('SELECT id, nom FROM plateformes WHERE user_id = ?').all(req.user.id);
|
||||
platIdSet = new Set(platRows.map(p => p.id));
|
||||
platNameMap = new Map(platRows.map(p => [normalizeName(p.nom), p.id]));
|
||||
platNomById = new Map(platRows.map(p => [p.id, p.nom]));
|
||||
}
|
||||
let invIdSet = new Set(), invNameMap = new Map(), invNomById = new Map();
|
||||
if (module === 'remboursements') {
|
||||
const invRows = db.prepare('SELECT id, nom_projet FROM investissements WHERE investisseur_id = ?').all(req.investisseur.id);
|
||||
invIdSet = new Set(invRows.map(i => i.id));
|
||||
invNameMap = new Map(invRows.map(i => [normalizeName(i.nom_projet), i.id]));
|
||||
invNomById = new Map(invRows.map(i => [i.id, i.nom_projet]));
|
||||
}
|
||||
|
||||
const duplicates = [];
|
||||
// Doublons INTERNES au fichier : /apply traite les lignes une à une dans une seule transaction,
|
||||
// donc une ligne répétée plus loin dans le même fichier se retrouve comparée à la précédente
|
||||
// occurrence tout juste "insérée" — pas seulement aux enregistrements déjà présents en base.
|
||||
// Comme cette route ne fait aucune écriture, on simule cet effet avec une map en mémoire :
|
||||
// clé de dédoublonnage → { row, incoming } de la première occurrence rencontrée.
|
||||
const seenDepotsRetraits = new Map();
|
||||
const seenInvestissements = new Map();
|
||||
const seenRemboursements = new Map();
|
||||
const seenPlateformes = new Map();
|
||||
const seenTauxPfu = new Map();
|
||||
|
||||
for (let idx = 0; idx < rows.length; idx++) {
|
||||
const row = rows[idx];
|
||||
const v = (target) => {
|
||||
const col = mapping[target];
|
||||
if (col && row[col] !== undefined && row[col] !== null && row[col] !== '') return row[col];
|
||||
return defaults[target];
|
||||
};
|
||||
const rowNum = idx + 2;
|
||||
|
||||
try {
|
||||
if (module === 'depots_retraits') {
|
||||
const plateformeId = resolveRefId(v('plateforme_id'), platIdSet, platNameMap, 'Plateforme');
|
||||
const dateOperation = normaliseDate(v('date_operation'));
|
||||
const type = normaliseType(v('type'));
|
||||
const montant = num(v('montant'));
|
||||
const incoming = {
|
||||
plateforme: platNomById.get(plateformeId) || '', date_operation: dateOperation,
|
||||
type, montant, libelle: v('libelle') || null, reference: v('reference') || null,
|
||||
};
|
||||
const existing = db.prepare(`
|
||||
SELECT * FROM depots_retraits
|
||||
WHERE investisseur_id = ? AND plateforme_id = ? AND date_operation = ?
|
||||
AND type = ? AND ABS(montant - ?) < 0.005
|
||||
LIMIT 1
|
||||
`).get(req.investisseur.id, plateformeId, dateOperation, type, montant);
|
||||
const key = `${plateformeId}|${dateOperation}|${type}|${montant.toFixed(2)}`;
|
||||
const seen = seenDepotsRetraits.get(key);
|
||||
if (existing) {
|
||||
duplicates.push({ row: rowNum, incoming, existing: {
|
||||
plateforme: platNomById.get(existing.plateforme_id) || '', date_operation: existing.date_operation,
|
||||
type: existing.type, montant: existing.montant, libelle: existing.libelle, reference: existing.reference,
|
||||
} });
|
||||
} else if (seen) {
|
||||
duplicates.push({ row: rowNum, matchedRow: seen.row, incoming, existing: seen.incoming });
|
||||
} else {
|
||||
seenDepotsRetraits.set(key, { row: rowNum, incoming });
|
||||
}
|
||||
} else if (module === 'investissements') {
|
||||
const plateformeId = resolveRefId(v('plateforme_id'), platIdSet, platNameMap, 'Plateforme');
|
||||
const nomProjet = String(v('nom_projet'));
|
||||
const dateSouscription = normaliseDate(v('date_souscription'));
|
||||
const incoming = {
|
||||
plateforme: platNomById.get(plateformeId) || '', nom_projet: nomProjet, date_souscription: dateSouscription,
|
||||
montant_investi: num(v('montant_investi')), taux_interet: v('taux_interet') || null,
|
||||
duree_mois: v('duree_mois') || null, statut: v('statut') || 'en_cours',
|
||||
};
|
||||
const existing = db.prepare(`
|
||||
SELECT * FROM investissements
|
||||
WHERE investisseur_id = ? AND plateforme_id = ? AND nom_projet = ? AND date_souscription = ?
|
||||
LIMIT 1
|
||||
`).get(req.investisseur.id, plateformeId, nomProjet, dateSouscription);
|
||||
const key = `${plateformeId}|${normalizeName(nomProjet)}|${dateSouscription}`;
|
||||
const seen = seenInvestissements.get(key);
|
||||
if (existing) {
|
||||
duplicates.push({ row: rowNum, incoming, existing: {
|
||||
plateforme: platNomById.get(existing.plateforme_id) || '', nom_projet: existing.nom_projet,
|
||||
date_souscription: existing.date_souscription, montant_investi: existing.montant_investi,
|
||||
taux_interet: existing.taux_interet, duree_mois: existing.duree_mois, statut: existing.statut,
|
||||
} });
|
||||
} else if (seen) {
|
||||
duplicates.push({ row: rowNum, matchedRow: seen.row, incoming, existing: seen.incoming });
|
||||
} else {
|
||||
seenInvestissements.set(key, { row: rowNum, incoming });
|
||||
}
|
||||
} else if (module === 'remboursements') {
|
||||
const investissementId = resolveRefId(v('investissement_id'), invIdSet, invNameMap, 'Investissement');
|
||||
const dateRemb = normaliseDate(v('date_remb'));
|
||||
const capital = num(v('capital'));
|
||||
const bruts = num(v('interets_bruts'));
|
||||
const incoming = {
|
||||
investissement: invNomById.get(investissementId) || '', date_remb: dateRemb, capital,
|
||||
cashback: num(v('cashback')), interets_bruts: bruts,
|
||||
prelev_sociaux: num(v('prelev_sociaux')), prelev_forfaitaire: num(v('prelev_forfaitaire')),
|
||||
statut: v('statut') || 'paye',
|
||||
};
|
||||
const existing = db.prepare(`
|
||||
SELECT * FROM remboursements
|
||||
WHERE investissement_id = ? AND date_remb = ?
|
||||
AND ABS(capital - ?) < 0.005 AND ABS(interets_bruts - ?) < 0.005
|
||||
LIMIT 1
|
||||
`).get(investissementId, dateRemb, capital, bruts);
|
||||
const key = `${investissementId}|${dateRemb}|${capital.toFixed(2)}|${bruts.toFixed(2)}`;
|
||||
const seen = seenRemboursements.get(key);
|
||||
if (existing) {
|
||||
duplicates.push({ row: rowNum, incoming, existing: {
|
||||
investissement: invNomById.get(existing.investissement_id) || '', date_remb: existing.date_remb,
|
||||
capital: existing.capital, cashback: existing.cashback, interets_bruts: existing.interets_bruts,
|
||||
prelev_sociaux: existing.prelev_sociaux, prelev_forfaitaire: existing.prelev_forfaitaire,
|
||||
statut: existing.statut,
|
||||
} });
|
||||
} else if (seen) {
|
||||
duplicates.push({ row: rowNum, matchedRow: seen.row, incoming, existing: seen.incoming });
|
||||
} else {
|
||||
seenRemboursements.set(key, { row: rowNum, incoming });
|
||||
}
|
||||
} else if (module === 'plateformes') {
|
||||
const nom = String(v('nom') || '').trim();
|
||||
if (!nom) continue;
|
||||
const incoming = { nom, url: v('url') || null, notes: v('notes') || null };
|
||||
const existing = db.prepare('SELECT * FROM plateformes WHERE user_id = ? AND nom = ?').get(req.user.id, nom);
|
||||
const key = nom;
|
||||
const seen = seenPlateformes.get(key);
|
||||
if (existing) {
|
||||
duplicates.push({ row: rowNum, incoming, existing: { nom: existing.nom, url: existing.url, notes: existing.notes } });
|
||||
} else if (seen) {
|
||||
duplicates.push({ row: rowNum, matchedRow: seen.row, incoming, existing: seen.incoming });
|
||||
} else {
|
||||
seenPlateformes.set(key, { row: rowNum, incoming });
|
||||
}
|
||||
} else if (module === 'taux_pfu') {
|
||||
const annee = parseInt(v('annee'), 10);
|
||||
if (!annee) continue;
|
||||
const incoming = {
|
||||
annee, pfu_total: num(v('pfu_total')), impot_revenu: num(v('impot_revenu')), prelev_sociaux: num(v('prelev_sociaux')),
|
||||
};
|
||||
const existing = db.prepare('SELECT * FROM taux_pfu WHERE annee = ?').get(annee);
|
||||
const key = annee;
|
||||
const seen = seenTauxPfu.get(key);
|
||||
if (existing) {
|
||||
duplicates.push({ row: rowNum, incoming, existing: {
|
||||
annee: existing.annee, pfu_total: existing.pfu_total, impot_revenu: existing.impot_revenu, prelev_sociaux: existing.prelev_sociaux,
|
||||
} });
|
||||
} else if (seen) {
|
||||
duplicates.push({ row: rowNum, matchedRow: seen.row, incoming, existing: seen.incoming });
|
||||
} else {
|
||||
seenTauxPfu.set(key, { row: rowNum, incoming });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Une ligne dont les références ne se résolvent pas sera de toute façon signalée en erreur
|
||||
// par /apply — on ne la fait pas échouer ici, la vérification des doublons est best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ total: rows.length, duplicates });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
router.post('/apply', (req, res, next) => {
|
||||
try {
|
||||
const { tempId, module, mapping, defaults = {}, duplicateDecisions = {} } = req.body || {};
|
||||
if (!tempId || !module || !mapping) {
|
||||
throw new HttpError(400, 'tempId, module and mapping are required');
|
||||
}
|
||||
const def = MODULES[module];
|
||||
if (!def) throw new HttpError(400, 'Unknown module');
|
||||
|
||||
// Require a specific investisseur for transactional modules
|
||||
if (INVESTISSEUR_SCOPED.includes(module)) {
|
||||
requireInvestisseur(req, res, () => {});
|
||||
@@ -122,8 +316,35 @@ router.post('/apply', (req, res, next) => {
|
||||
|
||||
const { rows } = parseFile(tempPath, origName || 'file.xlsx');
|
||||
|
||||
let inserted = 0, skipped = 0;
|
||||
// Résolution plateforme_id / investissement_id : accepte un ID numérique OU un nom
|
||||
// (ex. colonne "Plateforme" contenant "BienPrêter" plutôt qu'un ID) — cf. resolveRefId.
|
||||
let platIdSet = new Set(), platNameMap = new Map();
|
||||
if (module === 'depots_retraits' || module === 'investissements') {
|
||||
const platRows = db.prepare('SELECT id, nom FROM plateformes WHERE user_id = ?').all(req.user.id);
|
||||
platIdSet = new Set(platRows.map(p => p.id));
|
||||
platNameMap = new Map(platRows.map(p => [normalizeName(p.nom), p.id]));
|
||||
}
|
||||
let invIdSet = new Set(), invNameMap = new Map();
|
||||
if (module === 'remboursements') {
|
||||
const invRows = db.prepare('SELECT id, nom_projet FROM investissements WHERE investisseur_id = ?').all(req.investisseur.id);
|
||||
invIdSet = new Set(invRows.map(i => i.id));
|
||||
invNameMap = new Map(invRows.map(i => [normalizeName(i.nom_projet), i.id]));
|
||||
}
|
||||
|
||||
let inserted = 0, skipped = 0, duplicates = 0;
|
||||
const errors = [];
|
||||
// Investissements touchés par un remboursement importé (module 'remboursements') — sert à
|
||||
// reproduire après coup ce que fait la création manuelle d'un remboursement (statut auto
|
||||
// + réajustement de l'échéancier), que l'import ne faisait pas jusqu'ici.
|
||||
const affectedInvestissementIds = new Set();
|
||||
// Date la plus ancienne rencontrée par plateforme (dépôts/retraits + investissements)
|
||||
// — sert à détecter une date antérieure à la date d'ouverture déclarée sur la plateforme.
|
||||
const platMinDate = new Map();
|
||||
const trackPlatDate = (plateformeId, dateStr) => {
|
||||
if (!plateformeId || !dateStr) return;
|
||||
const cur = platMinDate.get(plateformeId);
|
||||
if (!cur || dateStr < cur) platMinDate.set(plateformeId, dateStr);
|
||||
};
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
for (let idx = 0; idx < rows.length; idx++) {
|
||||
@@ -136,24 +357,56 @@ router.post('/apply', (req, res, next) => {
|
||||
}
|
||||
return defaults[target];
|
||||
};
|
||||
// Décision prise par l'utilisateur dans la modale de doublons (voir /check-duplicates) :
|
||||
// 'accept' = importer quand même malgré la correspondance ; absent/'skip' = comportement
|
||||
// historique (la ligne en doublon est ignorée).
|
||||
const rowDecision = duplicateDecisions[String(idx + 2)];
|
||||
|
||||
if (module === 'depots_retraits') {
|
||||
const plateformeId = resolveRefId(v('plateforme_id'), platIdSet, platNameMap, 'Plateforme');
|
||||
const dateOperation = normaliseDate(v('date_operation'));
|
||||
const type = normaliseType(v('type'));
|
||||
const montant = num(v('montant'));
|
||||
trackPlatDate(plateformeId, dateOperation);
|
||||
|
||||
// Anti-doublon : même investisseur + plateforme + date + type + montant
|
||||
const dup = db.prepare(`
|
||||
SELECT id FROM depots_retraits
|
||||
WHERE investisseur_id = ? AND plateforme_id = ? AND date_operation = ?
|
||||
AND type = ? AND ABS(montant - ?) < 0.005
|
||||
LIMIT 1
|
||||
`).get(req.investisseur.id, plateformeId, dateOperation, type, montant);
|
||||
if (dup && rowDecision !== 'accept') { duplicates++; continue; }
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO depots_retraits
|
||||
(investisseur_id, plateforme_id, date_operation, type, montant, libelle, reference, source)
|
||||
VALUES (?,?,?,?,?,?,?,?)
|
||||
`).run(
|
||||
req.investisseur.id,
|
||||
Number(v('plateforme_id')),
|
||||
normaliseDate(v('date_operation')),
|
||||
normaliseType(v('type')),
|
||||
num(v('montant')),
|
||||
plateformeId,
|
||||
dateOperation,
|
||||
type,
|
||||
montant,
|
||||
v('libelle') || null,
|
||||
v('reference') || null,
|
||||
srcLabel,
|
||||
);
|
||||
|
||||
} else if (module === 'investissements') {
|
||||
const plateformeId = resolveRefId(v('plateforme_id'), platIdSet, platNameMap, 'Plateforme');
|
||||
const nomProjet = String(v('nom_projet'));
|
||||
const dateSouscription = normaliseDate(v('date_souscription'));
|
||||
trackPlatDate(plateformeId, dateSouscription);
|
||||
|
||||
// Anti-doublon : même investisseur + plateforme + nom du projet + date de souscription
|
||||
const dup = db.prepare(`
|
||||
SELECT id FROM investissements
|
||||
WHERE investisseur_id = ? AND plateforme_id = ? AND nom_projet = ? AND date_souscription = ?
|
||||
LIMIT 1
|
||||
`).get(req.investisseur.id, plateformeId, nomProjet, dateSouscription);
|
||||
if (dup && rowDecision !== 'accept') { duplicates++; continue; }
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO investissements
|
||||
(investisseur_id, plateforme_id, nom_projet, emetteur, date_souscription,
|
||||
@@ -162,10 +415,10 @@ router.post('/apply', (req, res, next) => {
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
`).run(
|
||||
req.investisseur.id,
|
||||
Number(v('plateforme_id')),
|
||||
String(v('nom_projet')),
|
||||
plateformeId,
|
||||
nomProjet,
|
||||
v('emetteur') || null,
|
||||
normaliseDate(v('date_souscription')),
|
||||
dateSouscription,
|
||||
v('date_premiere_echeance') ? normaliseDate(v('date_premiere_echeance')) : null,
|
||||
v('date_cible') ? normaliseDate(v('date_cible')) : null,
|
||||
num(v('montant_investi')),
|
||||
@@ -179,6 +432,8 @@ router.post('/apply', (req, res, next) => {
|
||||
);
|
||||
|
||||
} else if (module === 'remboursements') {
|
||||
const investissementId = resolveRefId(v('investissement_id'), invIdSet, invNameMap, 'Investissement');
|
||||
const dateRemb = normaliseDate(v('date_remb'));
|
||||
const capital = num(v('capital'));
|
||||
const cashback = num(v('cashback'));
|
||||
const bruts = num(v('interets_bruts'));
|
||||
@@ -186,37 +441,61 @@ router.post('/apply', (req, res, next) => {
|
||||
const pf = num(v('prelev_forfaitaire'));
|
||||
const interets_nets = Math.round((bruts - ps - pf) * 100) / 100;
|
||||
const net_recu = Math.round((capital + cashback + interets_nets) * 100) / 100;
|
||||
|
||||
// Anti-doublon : même investissement + date + capital + intérêts bruts
|
||||
const dup = db.prepare(`
|
||||
SELECT id FROM remboursements
|
||||
WHERE investissement_id = ? AND date_remb = ?
|
||||
AND ABS(capital - ?) < 0.005 AND ABS(interets_bruts - ?) < 0.005
|
||||
LIMIT 1
|
||||
`).get(investissementId, dateRemb, capital, bruts);
|
||||
if (dup && rowDecision !== 'accept') { duplicates++; continue; }
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO remboursements
|
||||
(investissement_id, date_remb, capital, cashback, interets_bruts, prelev_sociaux,
|
||||
prelev_forfaitaire, interets_nets, net_recu, statut, source)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
||||
`).run(
|
||||
Number(v('investissement_id')),
|
||||
normaliseDate(v('date_remb')),
|
||||
investissementId,
|
||||
dateRemb,
|
||||
capital, cashback, bruts, ps, pf, interets_nets, net_recu,
|
||||
v('statut') || 'paye',
|
||||
srcLabel,
|
||||
);
|
||||
affectedInvestissementIds.add(investissementId);
|
||||
|
||||
} else if (module === 'plateformes') {
|
||||
const nom = String(v('nom') || '').trim();
|
||||
if (!nom) throw new Error('Le champ nom est vide');
|
||||
const r = db.prepare(`
|
||||
INSERT OR IGNORE INTO plateformes (user_id, nom, url, notes)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(
|
||||
req.user.id,
|
||||
nom,
|
||||
v('url') || null,
|
||||
v('notes') || null,
|
||||
);
|
||||
// changes = 0 means the row was ignored (nom already exists)
|
||||
if (r.changes === 0) throw new Error(`Plateforme "${nom}" existe déjà — ignorée`);
|
||||
const existingPlat = db.prepare('SELECT id FROM plateformes WHERE user_id = ? AND nom = ?').get(req.user.id, nom);
|
||||
if (existingPlat) {
|
||||
// Une plateforme "nom" est unique par utilisateur/détenteur : impossible de créer un
|
||||
// second doublon en base. "Accepter" le doublon signifie donc ici mettre à jour la
|
||||
// fiche existante avec les valeurs importées, plutôt que de créer une nouvelle ligne.
|
||||
if (rowDecision !== 'accept') { duplicates++; continue; }
|
||||
db.prepare('UPDATE plateformes SET url = ?, notes = ? WHERE id = ?')
|
||||
.run(v('url') || null, v('notes') || null, existingPlat.id);
|
||||
} else {
|
||||
db.prepare(`
|
||||
INSERT INTO plateformes (user_id, nom, url, notes)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(
|
||||
req.user.id,
|
||||
nom,
|
||||
v('url') || null,
|
||||
v('notes') || null,
|
||||
);
|
||||
}
|
||||
|
||||
} else if (module === 'taux_pfu') {
|
||||
const annee = parseInt(v('annee'), 10);
|
||||
if (!annee || annee < 2000 || annee > 2100) throw new Error('Année invalide');
|
||||
const existingTaux = db.prepare('SELECT annee FROM taux_pfu WHERE annee = ?').get(annee);
|
||||
// Comportement historique : un taux existant est toujours écrasé par l'import (upsert).
|
||||
// On ne change ce comportement que si l'utilisateur a explicitement refusé le doublon
|
||||
// dans la modale de vérification — dans ce cas seulement, le taux existant est préservé.
|
||||
if (existingTaux && rowDecision === 'skip') { duplicates++; continue; }
|
||||
db.prepare(`
|
||||
INSERT INTO taux_pfu (annee, pfu_total, impot_revenu, prelev_sociaux)
|
||||
VALUES (?, ?, ?, ?)
|
||||
@@ -242,9 +521,51 @@ router.post('/apply', (req, res, next) => {
|
||||
});
|
||||
tx();
|
||||
|
||||
// Vérifie et notifie les données essentielles manquantes (taux, durée, type de
|
||||
// remboursement, date de première échéance) sur les investissements importés —
|
||||
// même logique que le job horaire, best-effort pour ne jamais bloquer l'import.
|
||||
if (module === 'investissements' && inserted > 0) {
|
||||
try { checkDonneesIncompletes(); } catch (e) {
|
||||
console.error('[imports] checkDonneesIncompletes après import a échoué :', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Reproduit après import ce que fait la création manuelle d'un remboursement :
|
||||
// passage automatique au statut "rembourse" si le capital est intégralement remboursé,
|
||||
// et réajustement de l'échéancier (troncature des échéances futures devenues caduques en
|
||||
// cas de remboursement anticipé). Sans cela, un investissement soldé via import restait
|
||||
// affiché "En cours" avec un échéancier complet non réajusté (contrairement à la saisie manuelle).
|
||||
if (module === 'remboursements' && affectedInvestissementIds.size > 0) {
|
||||
for (const invId of affectedInvestissementIds) {
|
||||
try {
|
||||
syncInvestissementStatut(invId);
|
||||
adjustSimulForActuals(db, invId);
|
||||
} catch (e) {
|
||||
console.error(`[imports] réajustement post-import échoué pour l'investissement #${invId} :`, e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Anomalie : une date importée précède la date d'ouverture déclarée sur la plateforme
|
||||
const anomalies = [];
|
||||
if (platMinDate.size > 0) {
|
||||
for (const [plateformeId, minDate] of platMinDate) {
|
||||
const plat = db.prepare('SELECT id, nom, date_ouverture FROM plateformes WHERE id = ? AND user_id = ?')
|
||||
.get(plateformeId, req.user.id);
|
||||
if (plat && plat.date_ouverture && minDate < plat.date_ouverture) {
|
||||
anomalies.push({
|
||||
plateforme_id: plat.id,
|
||||
plateforme_nom: plat.nom,
|
||||
date_ouverture_actuelle: plat.date_ouverture,
|
||||
date_detectee: minDate,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO imports (user_id, investisseur_id, module, filename, rows_total, rows_inserted, rows_skipped, mapping_json)
|
||||
VALUES (?,?,?,?,?,?,?,?)
|
||||
INSERT INTO imports (user_id, investisseur_id, module, filename, rows_total, rows_inserted, rows_skipped, rows_duplicates, mapping_json)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)
|
||||
`).run(
|
||||
req.user.id,
|
||||
req.investisseur?.id ?? null,
|
||||
@@ -253,13 +574,51 @@ router.post('/apply', (req, res, next) => {
|
||||
rows.length,
|
||||
inserted,
|
||||
skipped,
|
||||
duplicates,
|
||||
JSON.stringify(mapping),
|
||||
);
|
||||
|
||||
// Clean up temp file
|
||||
try { fs.unlinkSync(tempPath); } catch { /* */ }
|
||||
|
||||
res.json({ inserted, skipped, total: rows.length, errors: errors.slice(0, 50) });
|
||||
res.json({ inserted, skipped, duplicates, total: rows.length, errors: errors.slice(0, 50), anomalies });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/imports/template
|
||||
* Génère, à partir du fichier déjà analysé (preview) et du mappage/valeurs par défaut choisis,
|
||||
* un jeu de données complet : toutes les colonnes du module cible (obligatoires + optionnelles),
|
||||
* y compris celles qui n'ont pas de colonne source dans le fichier (laissées vides). L'utilisateur
|
||||
* peut ainsi compléter/enrichir ce fichier avant de le réimporter.
|
||||
*/
|
||||
router.post('/template', (req, res, next) => {
|
||||
try {
|
||||
const { tempId, module, mapping = {}, defaults = {} } = req.body || {};
|
||||
if (!tempId || !module) throw new HttpError(400, 'tempId et module sont requis');
|
||||
const def = MODULES[module];
|
||||
if (!def) throw new HttpError(400, 'Unknown module');
|
||||
|
||||
const tempPath = path.join(UPLOAD_DIR, tempId);
|
||||
if (!fs.existsSync(tempPath)) throw new HttpError(404, 'Uploaded file expired');
|
||||
|
||||
const origName = req.body.originalFilename || '';
|
||||
const { rows } = parseFile(tempPath, origName || 'file.xlsx');
|
||||
|
||||
const allTargets = [...def.requiredTargets, ...def.optionalTargets];
|
||||
const v = (row, target) => {
|
||||
const col = mapping[target];
|
||||
if (col && row[col] !== undefined && row[col] !== null && row[col] !== '') return row[col];
|
||||
return defaults[target] !== undefined ? defaults[target] : '';
|
||||
};
|
||||
|
||||
const templateRows = rows.map(row => {
|
||||
const out = {};
|
||||
for (const t of allTargets) out[t] = v(row, t);
|
||||
return out;
|
||||
});
|
||||
|
||||
res.json({ headers: allTargets, rows: templateRows });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
@@ -299,7 +658,7 @@ router.post('/dossier', (req, res, next) => {
|
||||
|
||||
/* ── 2. Chercher l'investissement existant (clé naturelle) */
|
||||
const existing = db.prepare(`
|
||||
SELECT id FROM investissements
|
||||
SELECT * FROM investissements
|
||||
WHERE investisseur_id = ? AND nom_projet = ? AND date_souscription = ?
|
||||
LIMIT 1
|
||||
`).get(req.investisseur.id, inv.nom_projet, inv.date_souscription);
|
||||
@@ -381,6 +740,20 @@ router.post('/dossier', (req, res, next) => {
|
||||
} else {
|
||||
/* ────────────── SCÉNARIO UPDATE ──────────────────────── */
|
||||
investissementId = existing.id;
|
||||
|
||||
const nouveau = {
|
||||
plateforme_id: plateformeId,
|
||||
date_premiere_echeance: inv.date_premiere_echeance || null,
|
||||
date_cible: inv.date_cible || null,
|
||||
date_debut_simul: inv.date_debut_simul || null,
|
||||
montant_investi: Number(inv.montant_investi),
|
||||
taux_interet: inv.taux_interet ?? null,
|
||||
duree_mois: inv.duree_mois ?? null,
|
||||
type_remb: inv.type_remb || 'in_fine',
|
||||
freq_interets: inv.freq_interets || 'mensuel',
|
||||
statut: inv.statut || 'en_cours',
|
||||
};
|
||||
|
||||
db.prepare(`
|
||||
UPDATE investissements SET
|
||||
plateforme_id = ?, emetteur = ?,
|
||||
@@ -390,11 +763,10 @@ router.post('/dossier', (req, res, next) => {
|
||||
reference = ?, notes = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
plateformeId, inv.emetteur || null,
|
||||
inv.date_premiere_echeance || null, inv.date_cible || null, inv.date_debut_simul || null,
|
||||
Number(inv.montant_investi), inv.taux_interet ?? null, inv.duree_mois ?? null,
|
||||
inv.type_remb || 'in_fine', inv.freq_interets || 'mensuel',
|
||||
inv.statut || 'en_cours',
|
||||
nouveau.plateforme_id, inv.emetteur || null,
|
||||
nouveau.date_premiere_echeance, nouveau.date_cible, nouveau.date_debut_simul,
|
||||
nouveau.montant_investi, nouveau.taux_interet, nouveau.duree_mois,
|
||||
nouveau.type_remb, nouveau.freq_interets, nouveau.statut,
|
||||
inv.reference || null, inv.notes || null,
|
||||
investissementId,
|
||||
);
|
||||
@@ -447,15 +819,29 @@ router.post('/dossier', (req, res, next) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Entrée d'historique de la mise à jour
|
||||
db.prepare(`
|
||||
INSERT INTO investissement_historique (investissement_id, type_evenement, changements)
|
||||
VALUES (?, 'import', ?)
|
||||
`).run(investissementId, JSON.stringify([{
|
||||
champ: 'import', label: 'Mise à jour dossier',
|
||||
ancienne_valeur: null,
|
||||
nouvelle_valeur: `${dossier.exported_at || 'inconnu'} — ${rembInserted} remb. ajouté(s), ${reinvInserted} réinvest. ajouté(s)`,
|
||||
}]));
|
||||
// Entrée d'historique de la mise à jour — diff précis champ par champ
|
||||
// (même logique que l'édition manuelle, pour que la date_cible/date_premiere_echeance
|
||||
// etc. écrasées par ce ré-import restent traçables dans l'historique du prêt).
|
||||
const changements = detectChangements(existing, nouveau);
|
||||
const notesImport = `Import dossier (${dossier.exported_at || 'date export inconnue'}) — ` +
|
||||
`${rembInserted} remb. ajouté(s), ${reinvInserted} réinvest. ajouté(s)`;
|
||||
|
||||
if (changements.length > 0) {
|
||||
recordHistory(investissementId, {
|
||||
type_evenement: detectTypeEvenement(changements),
|
||||
changements,
|
||||
notes: notesImport,
|
||||
});
|
||||
} else {
|
||||
recordHistory(investissementId, {
|
||||
type_evenement: 'import',
|
||||
changements: [{
|
||||
champ: 'import', label: 'Mise à jour dossier',
|
||||
ancienne_valeur: null,
|
||||
nouvelle_valeur: notesImport,
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
action = 'updated';
|
||||
}
|
||||
@@ -496,6 +882,35 @@ function stripAccents(s) {
|
||||
return String(s).normalize('NFD').replace(/[̀-ͯ]/g, '');
|
||||
}
|
||||
|
||||
/** Normalise un nom pour comparaison insensible à la casse/accents (ex. plateforme, projet) */
|
||||
function normalizeName(s) {
|
||||
return stripAccents(String(s || ''))
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Résout une valeur de colonne (ID numérique OU nom texte) vers l'ID cible réel.
|
||||
* - Si la valeur est un entier, elle doit correspondre à un ID existant (idSet).
|
||||
* - Sinon, elle est recherchée par nom normalisé (nameMap) — insensible casse/accents.
|
||||
* Lève une erreur explicite si rien ne correspond (la ligne sera ignorée par l'appelant).
|
||||
*/
|
||||
function resolveRefId(value, idSet, nameMap, label) {
|
||||
if (value === undefined || value === null || String(value).trim() === '') {
|
||||
throw new Error(`${label} manquant`);
|
||||
}
|
||||
const raw = String(value).trim();
|
||||
if (/^\d+$/.test(raw)) {
|
||||
const id = Number(raw);
|
||||
if (idSet.has(id)) return id;
|
||||
throw new Error(`${label} #${id} introuvable`);
|
||||
}
|
||||
const id = nameMap.get(normalizeName(raw));
|
||||
if (id == null) throw new Error(`${label} "${raw}" introuvable`);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Convertit une valeur monétaire en nombre (gère "€", espaces, virgule décimale) */
|
||||
function num(v) {
|
||||
if (v === undefined || v === null || v === '') return 0;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
@@ -143,7 +143,8 @@ function attachCategoriesInv(rows) {
|
||||
return rows.map(r => {
|
||||
const ownTags = map[r.id] || [];
|
||||
if (!r.referentiel_id) return { ...r, categories_inv: ownTags };
|
||||
const refTags = refCatMap[r.referentiel_id] || [];
|
||||
const excludedIds = new Set(JSON.parse(r.excluded_categories_inv_ids || '[]'));
|
||||
const refTags = (refCatMap[r.referentiel_id] || []).filter(t => !excludedIds.has(t.id));
|
||||
const refIdSet = new Set(refTags.map(t => t.id));
|
||||
const ownIds = new Set(ownTags.map(t => t.id));
|
||||
for (const tag of ownTags) tag.is_inherited = refIdSet.has(tag.id);
|
||||
@@ -192,7 +193,8 @@ function attachSecteursInv(rows) {
|
||||
return rows.map(r => {
|
||||
const ownTags = map[r.id] || [];
|
||||
if (!r.referentiel_id) return { ...r, secteurs_inv: ownTags };
|
||||
const refTags = refSectMap[r.referentiel_id] || [];
|
||||
const excludedIds = new Set(JSON.parse(r.excluded_secteurs_inv_ids || '[]'));
|
||||
const refTags = (refSectMap[r.referentiel_id] || []).filter(t => !excludedIds.has(t.id));
|
||||
const refIdSet = new Set(refTags.map(t => t.id));
|
||||
const ownIds = new Set(ownTags.map(t => t.id));
|
||||
for (const tag of ownTags) tag.is_inherited = refIdSet.has(tag.id);
|
||||
@@ -218,8 +220,9 @@ function syncCategories(platId, catIds) {
|
||||
// ── Lecture référentiel (tous users authentifiés) ─────────────────────────
|
||||
router.get('/referentiel-list', (_req, res) => {
|
||||
const rows = db.prepare(`
|
||||
SELECT pr.id, pr.nom, pr.domiciliation, pr.fiscalite,
|
||||
pr.taux_fiscalite_locale, pr.type_produit_fiscal, pr.logo_filename, pr.icone_filename
|
||||
SELECT pr.id, pr.nom, pr.url, pr.domiciliation, pr.fiscalite,
|
||||
pr.taux_fiscalite_locale, pr.type_produit_fiscal, pr.logo_filename, pr.icone_filename,
|
||||
pr.methode_remboursement, pr.type_pret_defaut, pr.freq_interets_defaut
|
||||
FROM plateformes_referentiel pr
|
||||
ORDER BY pr.nom
|
||||
`).all();
|
||||
@@ -233,6 +236,7 @@ router.get('/', (req, res) => {
|
||||
p.methode_remboursement, p.investisseur_id, p.date_ouverture, p.logo_filename, p.icone_filename, p.created_at,
|
||||
p.type_pret_defaut, p.freq_interets_defaut,
|
||||
p.referentiel_id, p.overridden_fields,
|
||||
p.excluded_categories_inv_ids, p.excluded_secteurs_inv_ids,
|
||||
pr.nom AS referentiel_nom, pr.description AS referentiel_description,
|
||||
inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom,
|
||||
inv.type AS investisseur_type, inv.type_fiscal AS investisseur_type_fiscal,
|
||||
@@ -251,7 +255,10 @@ router.get('/', (req, res) => {
|
||||
}));
|
||||
const withCats = attachCategories(req.user.id, enriched);
|
||||
const withCatsInv = attachCategoriesInv(withCats);
|
||||
const withAll = attachSecteursInv(withCatsInv);
|
||||
const withAll = attachSecteursInv(withCatsInv).map(r => {
|
||||
const { excluded_categories_inv_ids, excluded_secteurs_inv_ids, ...rest } = r;
|
||||
return rest;
|
||||
});
|
||||
res.json(withAll);
|
||||
});
|
||||
|
||||
@@ -654,12 +661,34 @@ router.put('/:id', (req, res, next) => {
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ── Correction rapide de la date d'ouverture (ex. anomalie détectée à l'import) ──
|
||||
router.patch('/:id/date-ouverture', (req, res, next) => {
|
||||
try {
|
||||
const schema = z.object({ date_ouverture: z.string().regex(/^\d{4}-\d{2}-\d{2}$/) });
|
||||
const { date_ouverture } = schema.parse(req.body);
|
||||
const r = db.prepare('UPDATE plateformes SET date_ouverture = ? WHERE id = ? AND user_id = ?')
|
||||
.run(date_ouverture, req.params.id, req.user.id);
|
||||
if (r.changes === 0) throw new HttpError(404, 'Not found');
|
||||
res.json({ id: Number(req.params.id), date_ouverture });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
router.delete('/:id', (req, res, next) => {
|
||||
try {
|
||||
// Récupère le logo avant suppression pour effacer le fichier
|
||||
const plat = db.prepare('SELECT logo_filename FROM plateformes WHERE id=? AND user_id=?')
|
||||
.get(req.params.id, req.user.id);
|
||||
if (!plat) throw new HttpError(404, 'Not found');
|
||||
|
||||
// plateforme_id est en ON DELETE CASCADE sur investissements/depots_retraits (nécessaire pour
|
||||
// permettre la suppression complète d'un compte) — on protège donc ici, explicitement, contre
|
||||
// la suppression accidentelle d'une plateforme qui a encore des données rattachées.
|
||||
const { n: nbInv } = db.prepare('SELECT COUNT(*) AS n FROM investissements WHERE plateforme_id = ?').get(req.params.id);
|
||||
const { n: nbDr } = db.prepare('SELECT COUNT(*) AS n FROM depots_retraits WHERE plateforme_id = ?').get(req.params.id);
|
||||
if (nbInv > 0 || nbDr > 0) {
|
||||
throw new HttpError(400, `Impossible de supprimer cette plateforme : elle a encore ${nbInv} investissement(s) et ${nbDr} mouvement(s) de dépôt/retrait enregistrés. Supprimez-les d'abord.`);
|
||||
}
|
||||
|
||||
const r = db.prepare('DELETE FROM plateformes WHERE id=? AND user_id=?')
|
||||
.run(req.params.id, req.user.id);
|
||||
if (r.changes === 0) throw new HttpError(404, 'Not found');
|
||||
@@ -671,6 +700,61 @@ router.delete('/:id', (req, res, next) => {
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ── Purge des données d'une plateforme (dépôts/retraits, investissements, remboursements) ──
|
||||
// La plateforme elle-même n'est jamais supprimée par cette route : seules les données
|
||||
// rattachées le sont, afin de permettre une réimportation propre derrière.
|
||||
const PurgeSchema = z.object({
|
||||
scope: z.enum(['all', 'depots_retraits', 'investissements', 'remboursements']),
|
||||
confirmNom: z.string().min(1),
|
||||
});
|
||||
|
||||
router.post('/:id/purge-donnees', (req, res, next) => {
|
||||
try {
|
||||
const { scope, confirmNom } = PurgeSchema.parse(req.body);
|
||||
const plat = db.prepare('SELECT id, nom FROM plateformes WHERE id = ? AND user_id = ?')
|
||||
.get(req.params.id, req.user.id);
|
||||
if (!plat) throw new HttpError(404, 'Plateforme introuvable');
|
||||
|
||||
// Sécurité supplémentaire : le nom retapé doit correspondre exactement (protection
|
||||
// contre une confirmation validée par erreur sur la mauvaise plateforme).
|
||||
if (confirmNom.trim() !== plat.nom) {
|
||||
throw new HttpError(400, 'Le nom saisi ne correspond pas exactement au nom de la plateforme.');
|
||||
}
|
||||
|
||||
const platId = plat.id;
|
||||
const counts = { depots_retraits: 0, investissements: 0, remboursements: 0, simul_remboursements: 0 };
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
if (scope === 'depots_retraits' || scope === 'all') {
|
||||
counts.depots_retraits = db.prepare('DELETE FROM depots_retraits WHERE plateforme_id = ?').run(platId).changes;
|
||||
}
|
||||
if (scope === 'remboursements') {
|
||||
// Ne touche que les remboursements réels ; les investissements et leur échéancier restent.
|
||||
counts.remboursements = db.prepare(`
|
||||
DELETE FROM remboursements
|
||||
WHERE investissement_id IN (SELECT id FROM investissements WHERE plateforme_id = ?)
|
||||
`).run(platId).changes;
|
||||
}
|
||||
if (scope === 'investissements' || scope === 'all') {
|
||||
// Compte avant suppression : les remboursements et l'échéancier simulé sont
|
||||
// supprimés en cascade (ON DELETE CASCADE) par la suppression des investissements.
|
||||
counts.remboursements = db.prepare(`
|
||||
SELECT COUNT(*) AS n FROM remboursements
|
||||
WHERE investissement_id IN (SELECT id FROM investissements WHERE plateforme_id = ?)
|
||||
`).get(platId).n;
|
||||
counts.simul_remboursements = db.prepare(`
|
||||
SELECT COUNT(*) AS n FROM simul_remboursements
|
||||
WHERE investissement_id IN (SELECT id FROM investissements WHERE plateforme_id = ?)
|
||||
`).get(platId).n;
|
||||
counts.investissements = db.prepare('DELETE FROM investissements WHERE plateforme_id = ?').run(platId).changes;
|
||||
}
|
||||
});
|
||||
tx();
|
||||
|
||||
res.json({ ok: true, scope, plateforme: plat.nom, counts });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ── Reset aux valeurs du référentiel ──────────────────────────────────────
|
||||
router.post('/:id/reset', (req, res, next) => {
|
||||
try {
|
||||
@@ -684,12 +768,14 @@ router.post('/:id/reset', (req, res, next) => {
|
||||
|
||||
db.prepare(`
|
||||
UPDATE plateformes
|
||||
SET nom=?, domiciliation=?, fiscalite=?, taux_fiscalite_locale=?,
|
||||
type_produit_fiscal=?, logo_filename=?, overridden_fields='[]'
|
||||
SET nom=?, url=?, domiciliation=?, fiscalite=?, taux_fiscalite_locale=?,
|
||||
type_produit_fiscal=?, logo_filename=?, methode_remboursement=?,
|
||||
type_pret_defaut=?, freq_interets_defaut=?, overridden_fields='[]'
|
||||
WHERE id=? AND user_id=?
|
||||
`).run(
|
||||
ref.nom, ref.domiciliation, ref.fiscalite, ref.taux_fiscalite_locale ?? null,
|
||||
ref.type_produit_fiscal, ref.logo_filename ?? null,
|
||||
ref.nom, ref.url ?? null, ref.domiciliation, ref.fiscalite, ref.taux_fiscalite_locale ?? null,
|
||||
ref.type_produit_fiscal, ref.logo_filename ?? null, ref.methode_remboursement || 'portefeuille',
|
||||
ref.type_pret_defaut ?? null, ref.freq_interets_defaut ?? null,
|
||||
req.params.id, req.user.id
|
||||
);
|
||||
|
||||
|
||||
@@ -825,7 +825,7 @@ router.post('/:id/push', (req, res, next) => {
|
||||
).all(ref.id).map(r => r.secteur_id);
|
||||
|
||||
const plateformes = db.prepare(
|
||||
'SELECT id, overridden_fields FROM plateformes WHERE referentiel_id = ?'
|
||||
'SELECT id, overridden_fields, excluded_categories_inv_ids, excluded_secteurs_inv_ids FROM plateformes WHERE referentiel_id = ?'
|
||||
).all(ref.id);
|
||||
|
||||
let nb_updated = 0;
|
||||
@@ -853,14 +853,19 @@ router.post('/:id/push', (req, res, next) => {
|
||||
}
|
||||
|
||||
// ── Catégories/secteurs ──
|
||||
// Méthode dure (force) : réinitialise aussi les exclusions utilisateur et remplace toutes les associations.
|
||||
// Méthode douce (défaut) : respecte les tags que l'utilisateur a explicitement retirés (excluded_*_ids).
|
||||
const excludedCatIds = force ? new Set() : new Set(JSON.parse(plat.excluded_categories_inv_ids || '[]'));
|
||||
const excludedSectIds = force ? new Set() : new Set(JSON.parse(plat.excluded_secteurs_inv_ids || '[]'));
|
||||
|
||||
if (force) {
|
||||
// Méthode dure : remplace toutes les associations
|
||||
db.prepare('DELETE FROM plateforme_categories_inv WHERE plateforme_id = ?').run(plat.id);
|
||||
db.prepare('DELETE FROM plateforme_secteurs_inv WHERE plateforme_id = ?').run(plat.id);
|
||||
db.prepare("UPDATE plateformes SET excluded_categories_inv_ids = '[]', excluded_secteurs_inv_ids = '[]' WHERE id = ?").run(plat.id);
|
||||
}
|
||||
// Méthode douce : INSERT OR IGNORE (n'écrase pas les associations existantes)
|
||||
for (const catId of refCatIds) insCat.run(plat.id, catId);
|
||||
for (const sectId of refSectIds) insSect.run(plat.id, sectId);
|
||||
// INSERT OR IGNORE (n'écrase pas les associations existantes), en sautant les tags exclus par l'utilisateur
|
||||
for (const catId of refCatIds) if (!excludedCatIds.has(catId)) insCat.run(plat.id, catId);
|
||||
for (const sectId of refSectIds) if (!excludedSectIds.has(sectId)) insSect.run(plat.id, sectId);
|
||||
|
||||
// Sync investissements actifs de la plateforme
|
||||
const invs = db.prepare(
|
||||
@@ -871,8 +876,8 @@ router.post('/:id/push', (req, res, next) => {
|
||||
db.prepare('DELETE FROM investissement_categories_inv WHERE investissement_id = ?').run(inv.id);
|
||||
db.prepare('DELETE FROM investissement_secteurs_inv WHERE investissement_id = ?').run(inv.id);
|
||||
}
|
||||
for (const catId of refCatIds) insInvCat.run(inv.id, catId);
|
||||
for (const sectId of refSectIds) insInvSect.run(inv.id, sectId);
|
||||
for (const catId of refCatIds) if (!excludedCatIds.has(catId)) insInvCat.run(inv.id, catId);
|
||||
for (const sectId of refSectIds) if (!excludedSectIds.has(sectId)) insInvSect.run(inv.id, sectId);
|
||||
}
|
||||
|
||||
nb_updated++;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -21,8 +21,7 @@ router.get('/', (req, res) => {
|
||||
(SELECT COUNT(*) FROM plateforme_secteurs_inv ps WHERE ps.secteur_id = s.id
|
||||
AND ps.plateforme_id IN (
|
||||
SELECT p.id FROM plateformes p
|
||||
JOIN investisseurs i ON i.id = p.investisseur_id
|
||||
WHERE i.user_id = ?
|
||||
WHERE p.user_id = ?
|
||||
)
|
||||
) AS nb_plateformes,
|
||||
(SELECT COUNT(*) FROM investissement_secteurs_inv is2 WHERE is2.secteur_id = s.id
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+241
@@ -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",
|
||||
|
||||
@@ -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 |
@@ -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.
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)
|
||||
@@ -49,6 +49,26 @@ export default function App() {
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Webapp installée sur l'écran d'accueil (iPad) : mode standalone, donc pas
|
||||
// de bouton reload ni de pull-to-refresh natif. Si l'app est restée en
|
||||
// arrière-plan un moment (cas typique : on la rouvre depuis l'icône des
|
||||
// heures/jours après un déploiement serveur), on force un reload complet
|
||||
// au retour au premier plan pour repartir sur un bundle et des données à jour.
|
||||
useEffect(() => {
|
||||
let hiddenAt = null;
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
hiddenAt = Date.now();
|
||||
} else if (document.visibilityState === 'visible' && hiddenAt) {
|
||||
const awayMs = Date.now() - hiddenAt;
|
||||
hiddenAt = null;
|
||||
if (awayMs > 2 * 60 * 1000) window.location.reload();
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
return () => document.removeEventListener('visibilitychange', onVisibility);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
|
||||
+8
-4
@@ -34,7 +34,7 @@ export const api = {
|
||||
const qs = params ? '?' + new URLSearchParams(
|
||||
Object.entries(params).filter(([, v]) => v !== undefined && v !== null && v !== '')
|
||||
).toString() : '';
|
||||
return fetch(BASE + path + qs, { headers: authHeaders() }).then(handle);
|
||||
return fetch(BASE + path + qs, { headers: authHeaders(), cache: 'no-store' }).then(handle);
|
||||
},
|
||||
post: (path, body) =>
|
||||
fetch(BASE + path, {
|
||||
@@ -54,14 +54,18 @@ export const api = {
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify(body),
|
||||
}).then(handle),
|
||||
del: (path) =>
|
||||
fetch(BASE + path, { method: 'DELETE', headers: authHeaders() }).then(handle),
|
||||
del: (path, body) =>
|
||||
fetch(BASE + path, {
|
||||
method: 'DELETE',
|
||||
headers: body ? { 'Content-Type': 'application/json', ...authHeaders() } : authHeaders(),
|
||||
...(body ? { body: JSON.stringify(body) } : {}),
|
||||
}).then(handle),
|
||||
upload: (path, formData) =>
|
||||
fetch(BASE + path, { method: 'POST', body: formData, headers: authHeaders() }).then(handle),
|
||||
postForm: (path, formData) =>
|
||||
fetch(BASE + path, { method: 'POST', body: formData, headers: authHeaders() }).then(handle),
|
||||
blob: (path) =>
|
||||
fetch(BASE + path, { headers: authHeaders() }).then(async res => {
|
||||
fetch(BASE + path, { headers: authHeaders(), cache: 'no-store' }).then(async res => {
|
||||
if (!res.ok) { const t = await res.text(); throw new Error(t || res.statusText); }
|
||||
return res.blob();
|
||||
}),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
|
||||
@@ -67,10 +67,15 @@ export function AuthProvider({ children }) {
|
||||
return r.user;
|
||||
};
|
||||
|
||||
const deleteAccount = async (password) => {
|
||||
await api.del('/auth/me', { password });
|
||||
logout();
|
||||
};
|
||||
|
||||
const isAdmin = user?.role === 'admin';
|
||||
|
||||
return (
|
||||
<AuthCtx.Provider value={{ token, user, loading, login, completeLogin, register, logout, updateUser, isAdmin }}>
|
||||
<AuthCtx.Provider value={{ token, user, loading, login, completeLogin, register, logout, updateUser, deleteAccount, isAdmin }}>
|
||||
{children}
|
||||
</AuthCtx.Provider>
|
||||
);
|
||||
|
||||
+499
-12
@@ -1,21 +1,107 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { withDevOverrides } from '../utils/devOverrides.js';
|
||||
|
||||
/** Nom de fichier à partir de la question (slug, sans accents). */
|
||||
function slugify(text) {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.normalize('NFD').replace(new RegExp('[\\u0300-\\u036f]', 'g'), '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 60);
|
||||
}
|
||||
|
||||
/* ── Accordéon FAQ ───────────────────────────────────────────── */
|
||||
function FaqItem({ question, children }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
/* `openQuestion`/`setOpenQuestion` sont partagés par toutes les FaqItem d'une
|
||||
même page (état remonté dans le composant parent) : une seule question
|
||||
ouverte à la fois, ouvrir la suivante referme automatiquement les autres. */
|
||||
function FaqItem({ question, children, openQuestion, setOpenQuestion }) {
|
||||
const open = openQuestion === question;
|
||||
const toggle = () => setOpenQuestion(open ? null : question);
|
||||
const [pdfState, setPdfState] = useState('idle'); // idle | generating | error
|
||||
const contentRef = useRef(null);
|
||||
|
||||
const handleDownloadPdf = async () => {
|
||||
if (!contentRef.current || pdfState === 'generating') return;
|
||||
setPdfState('generating');
|
||||
// Insère le titre directement dans le bloc réel (contentRef), le temps de
|
||||
// la capture uniquement, puis le retire — évite de le dupliquer en
|
||||
// permanence à l'écran tout en réutilisant l'élément réellement rendu
|
||||
// (un conteneur hors-écran séparé est capturé vide par html2canvas :
|
||||
// sa zone de rendu ne suit pas un élément poussé loin hors du viewport).
|
||||
let heading = null;
|
||||
try {
|
||||
const { default: html2pdf } = await import('html2pdf.js');
|
||||
// Fond capturé = fond réel du bloc (var(--surface)) : reste cohérent
|
||||
// que le thème actif soit clair ou sombre, plutôt qu'un blanc forcé
|
||||
// qui casserait le contraste du texte en mode sombre.
|
||||
const bgColor = getComputedStyle(contentRef.current).backgroundColor || '#ffffff';
|
||||
|
||||
heading = document.createElement('h3');
|
||||
heading.textContent = question;
|
||||
heading.style.margin = '0 0 12px';
|
||||
heading.style.color = 'var(--text)';
|
||||
heading.style.fontSize = '1.1rem';
|
||||
contentRef.current.insertBefore(heading, contentRef.current.firstChild);
|
||||
|
||||
await html2pdf()
|
||||
.set({
|
||||
margin: 28,
|
||||
filename: `faq-${slugify(question) || 'crowdlending'}.pdf`,
|
||||
image: { type: 'jpeg', quality: 0.95 },
|
||||
html2canvas: { scale: 2, useCORS: true, backgroundColor: bgColor },
|
||||
jsPDF: { unit: 'pt', format: 'a4', orientation: 'portrait' },
|
||||
// 'avoid-all' : ne coupe jamais un élément (image, bloc de code,
|
||||
// ligne de tableau…) au milieu — le pousse entièrement sur la page
|
||||
// suivante à la place. C'est ce qui manquait avec le moteur HTML
|
||||
// interne de jsPDF (doc.html()), qui tranchait au hasard.
|
||||
pagebreak: { mode: ['avoid-all', 'css'] },
|
||||
})
|
||||
.from(contentRef.current)
|
||||
.toPdf()
|
||||
.get('pdf')
|
||||
.then((pdf) => {
|
||||
// Numérotation en bas à droite, une fois le nombre total de pages connu.
|
||||
const total = pdf.internal.getNumberOfPages();
|
||||
for (let i = 1; i <= total; i++) {
|
||||
pdf.setPage(i);
|
||||
pdf.setFontSize(9);
|
||||
pdf.setTextColor(150);
|
||||
pdf.text(
|
||||
`${i} / ${total}`,
|
||||
pdf.internal.pageSize.getWidth() - 28,
|
||||
pdf.internal.pageSize.getHeight() - 16,
|
||||
{ align: 'right' }
|
||||
);
|
||||
}
|
||||
})
|
||||
.save();
|
||||
setPdfState('idle');
|
||||
} catch (e) {
|
||||
console.error('Échec de la génération du PDF :', e);
|
||||
setPdfState('error');
|
||||
} finally {
|
||||
if (heading && heading.parentNode) heading.parentNode.removeChild(heading);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
borderBottom: '1px solid var(--border)',
|
||||
padding: '0',
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 10,
|
||||
boxShadow: 'var(--shadow)',
|
||||
padding: '0 20px',
|
||||
marginBottom: 12,
|
||||
}}>
|
||||
<button
|
||||
onClick={() => setOpen(o => !o)}
|
||||
onClick={toggle}
|
||||
style={{
|
||||
width: '100%', textAlign: 'left', background: 'none', border: 'none',
|
||||
padding: '14px 0', cursor: 'pointer', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'space-between', gap: 12,
|
||||
color: 'var(--text)', fontSize: 'var(--fs-base)', fontWeight: 500,
|
||||
color: 'var(--text)', fontSize: '1.05rem', fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<span>{question}</span>
|
||||
@@ -29,16 +115,81 @@ function FaqItem({ question, children }) {
|
||||
</button>
|
||||
{open && (
|
||||
<div style={{
|
||||
paddingBottom: 16, color: 'var(--text-muted)',
|
||||
paddingBottom: 20, color: 'var(--text-muted)',
|
||||
fontSize: 'var(--fs-sm)', lineHeight: 1.7,
|
||||
}}>
|
||||
{children}
|
||||
<div ref={contentRef} style={{ background: 'var(--surface)' }}>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleDownloadPdf}
|
||||
disabled={pdfState === 'generating'}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6, marginTop: 16,
|
||||
padding: '7px 14px', border: '1px solid var(--border)', borderRadius: 8,
|
||||
background: 'var(--surface-2)', color: 'var(--text)',
|
||||
fontSize: 'var(--fs-sm)', fontWeight: 500,
|
||||
cursor: pdfState === 'generating' ? 'default' : 'pointer',
|
||||
opacity: pdfState === 'generating' ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
|
||||
<path d="M12 3v12" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<path d="M5 21h14" />
|
||||
</svg>
|
||||
{pdfState === 'generating' ? 'Génération du PDF…' : 'Télécharger cette FAQ en PDF'}
|
||||
</button>
|
||||
{pdfState === 'error' && (
|
||||
<p style={{ color: 'var(--danger)', fontSize: 'var(--fs-sm)', margin: '8px 0 0' }}>
|
||||
La génération du PDF a échoué. Réessayez, ou imprimez la page (Ctrl+P / Cmd+P) et choisissez
|
||||
« Enregistrer en PDF ».
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Titre de section (regroupement thématique de la FAQ) ──────── */
|
||||
function FaqSectionTitle({ children, first }) {
|
||||
return (
|
||||
<h3 style={{
|
||||
margin: first ? '0 0 12px' : '32px 0 12px',
|
||||
fontSize: '0.8rem', fontWeight: 700, textTransform: 'uppercase',
|
||||
letterSpacing: '0.06em', color: 'var(--text-muted)',
|
||||
}}>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Lien de téléchargement (fichiers statiques dans public/) ──── */
|
||||
function DownloadLink({ href, children }) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
download
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
color: 'var(--primary)', fontSize: 'var(--fs-sm)', fontWeight: 500,
|
||||
textDecoration: 'none', margin: '0 0 12px',
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
|
||||
<path d="M12 3v12" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<path d="M5 21h14" />
|
||||
</svg>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Navigation ─────────────────────────────────────────────── */
|
||||
const NAV = [
|
||||
{
|
||||
@@ -59,6 +210,17 @@ const NAV = [
|
||||
export default function Aide() {
|
||||
const { search } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [openQuestion, setOpenQuestion] = useState(null); // question ouverte dans la FAQ (une seule à la fois)
|
||||
const [appInfo, setAppInfo] = useState({}); // { appUrl, mcpUrl } — valeurs brutes de la base, telles quelles
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/app-info').then(r => r.json()).then(setAppInfo).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// URL MCP annoncée comme « déjà pré-remplie » : doit refléter ce que
|
||||
// Mon compte → Serveur MCP affichera réellement, y compris en dev (où
|
||||
// cette page-là retombe elle-même sur localhost via withDevOverrides).
|
||||
const mcpConfigUrl = withDevOverrides(appInfo).mcpUrl || 'https://mcp.<votre domaine>/mcp';
|
||||
|
||||
const section = new URLSearchParams(search).get('section') || 'faq';
|
||||
const setSection = (s) => navigate(`/aide?section=${s}`, { replace: true });
|
||||
@@ -86,9 +248,11 @@ export default function Aide() {
|
||||
|
||||
{section === 'faq' && (
|
||||
<div>
|
||||
<h2 style={{ marginTop: 0, marginBottom: 24 }}>Questions fréquentes</h2>
|
||||
<h2 style={{ marginTop: 0, marginBottom: 24, fontSize: '1.6rem', fontWeight: 700 }}>Questions fréquentes</h2>
|
||||
|
||||
<FaqItem question="Comment est calculé le solde du porte-monnaie d'une plateforme ?">
|
||||
<FaqSectionTitle first>Comprendre la plateforme</FaqSectionTitle>
|
||||
|
||||
<FaqItem question="Comment est calculé le solde du porte-monnaie d'une plateforme ?" openQuestion={openQuestion} setOpenQuestion={setOpenQuestion}>
|
||||
<p style={{ marginTop: 0 }}>
|
||||
Le solde du porte-monnaie représente les liquidités disponibles sur une plateforme,
|
||||
c'est-à-dire l'argent que vous pouvez retirer ou réinvestir. Il est calculé comme suit :
|
||||
@@ -137,7 +301,7 @@ export default function Aide() {
|
||||
permettant de réconcilier de micro-écarts de calcul (par exemple un arrondi de centimes sur la fiscalité).</p>
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Comment mettre en place un réinvestissement automatique des intérêts ?">
|
||||
<FaqItem question="Comment mettre en place un réinvestissement automatique des intérêts ?" openQuestion={openQuestion} setOpenQuestion={setOpenQuestion}>
|
||||
<p style={{ marginTop: 0 }}>
|
||||
Le réinvestissement automatique permet de capitaliser les intérêts perçus après chaque remboursement,
|
||||
sans aucune saisie manuelle. Les intérêts sont automatiquement réinjectés dans le capital du prêt,
|
||||
@@ -179,6 +343,329 @@ export default function Aide() {
|
||||
</p>
|
||||
</FaqItem>
|
||||
|
||||
<FaqSectionTitle>API et Serveur MCP pour l'IA</FaqSectionTitle>
|
||||
|
||||
<FaqItem question="Comment configurer le serveur MCP pour l'utiliser dans Claude Desktop" openQuestion={openQuestion} setOpenQuestion={setOpenQuestion}>
|
||||
<p style={{ marginTop: 0 }}>
|
||||
Le serveur MCP tourne déjà en continu (service Docker <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending-mcp</code>,
|
||||
exposé via Traefik) — vous n'avez rien à installer ni à laisser tourner sur votre machine. Il
|
||||
suffit de connecter Claude Desktop à l'URL publique.
|
||||
</p>
|
||||
|
||||
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Étapes</h4>
|
||||
<ol style={{ margin: '0 0 12px 16px', paddingLeft: 0, lineHeight: 1.8 }}>
|
||||
<li>Générez une clé API : <strong style={{ color: 'var(--text)' }}>Mon compte → Clés API → Nouvelle clé</strong> (par exemple nommée « Claude Desktop »).</li>
|
||||
<li>Allez dans <strong style={{ color: 'var(--text)' }}>Mon compte → Serveur MCP</strong> et renseignez l'URL publique
|
||||
(<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>{mcpConfigUrl}</code>{appInfo.mcpUrl ? ' — déjà pré-rempli si vous ne l\'avez pas modifié' : ''}) ainsi que la clé générée.</li>
|
||||
<li>Copiez la configuration générée dans <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>claude_desktop_config.json</code> (Réglages
|
||||
→ Développeur → Serveurs MCP locaux → Modifier la config) — le mécanisme passe par
|
||||
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}> mcp-remote</code> (avec le wrapper <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>cmd /c</code> sous
|
||||
Windows), généré automatiquement pour vous.</li>
|
||||
<li>Redémarrez complètement Claude Desktop.</li>
|
||||
</ol>
|
||||
|
||||
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Sécurité</h4>
|
||||
<ul style={{ margin: '0 0 12px 16px', paddingLeft: 0, lineHeight: 1.8 }}>
|
||||
<li>Le point d'entrée public n'a <strong style={{ color: 'var(--text)' }}>volontairement aucune restriction d'IP</strong> (accessible
|
||||
depuis n'importe où, pour un usage nomade) : la clé API est donc la seule barrière d'accès. Traitez-la comme un
|
||||
mot de passe, et révoquez-la immédiatement en cas de doute (Mon compte → Clés API).</li>
|
||||
<li>Limite de 60 requêtes/minute par IP (au-delà, erreur 429) et fermeture automatique des sessions inactives
|
||||
depuis plus de 30 minutes — aucune donnée de session n'est conservée entre deux connexions.</li>
|
||||
<li>L'outil <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_fetch_url</code> (lecture d'une page web arbitraire) reste
|
||||
<strong style={{ color: 'var(--text)' }}> désactivé</strong> sur ce serveur.</li>
|
||||
</ul>
|
||||
<p style={{ marginBottom: 0 }}>
|
||||
Si la connexion reste bloquée sans erreur visible, la cause est presque toujours la même :
|
||||
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}> npx</code> qui échoue silencieusement à joindre le registre npm — voir la
|
||||
section Dépannage de <strong style={{ color: 'var(--text)' }}>Mon compte → Serveur MCP</strong>.
|
||||
</p>
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Comment utiliser le serveur MCP au quotidien ? (fonctions disponibles et exemples)" openQuestion={openQuestion} setOpenQuestion={setOpenQuestion}>
|
||||
<p style={{ marginTop: 0 }}>
|
||||
Une fois connecté, Claude (Desktop ou tout autre client MCP) peut consulter votre portefeuille en langage
|
||||
naturel — il choisit lui-même le bon outil selon votre question. Le serveur est <strong style={{ color: 'var(--text)' }}>strictement
|
||||
en lecture seule</strong> : aucune donnée n'est jamais créée, modifiée ou supprimée depuis une conversation. Toute
|
||||
saisie (nouvel investissement, remboursement…) reste manuelle dans l'application.
|
||||
</p>
|
||||
|
||||
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Fonctions disponibles</h4>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 'var(--fs-sm)', margin: '0 0 14px' }}>
|
||||
<tbody>
|
||||
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
|
||||
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_get_investisseur</code>
|
||||
</td>
|
||||
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>Profil de l'investisseur lié à la clé API (nom, type famille/entreprise, régime fiscal). Avec une clé « Famille et entreprises », renvoie la liste des profils du foyer.</td>
|
||||
</tr>
|
||||
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
|
||||
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_get_dashboard</code>
|
||||
</td>
|
||||
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>KPIs du portefeuille : capital investi, capital en risque, montant remboursé, intérêts bruts/nets, dépôts/retraits — filtrable par année.</td>
|
||||
</tr>
|
||||
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
|
||||
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_list_investissements</code>
|
||||
</td>
|
||||
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>Liste des investissements (projet, émetteur, plateforme, montant, taux, durée, statut), filtrable par statut.</td>
|
||||
</tr>
|
||||
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
|
||||
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_get_investissement</code>
|
||||
</td>
|
||||
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>Détail complet d'un investissement (par id), y compris la liste de ses remboursements réels perçus.</td>
|
||||
</tr>
|
||||
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
|
||||
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_list_remboursements</code>
|
||||
</td>
|
||||
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>Historique des remboursements perçus (toutes plateformes), filtrable par période.</td>
|
||||
</tr>
|
||||
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
|
||||
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_list_depots_retraits</code>
|
||||
</td>
|
||||
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>Historique des mouvements de cash (dépôts et retraits), du plus récent au plus ancien.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
|
||||
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_fetch_url</code>
|
||||
</td>
|
||||
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>
|
||||
<em>Développement local uniquement</em>, désactivé par défaut. Lit une page web (ex. annonce de projet sur
|
||||
une plateforme) et en extrait le texte propre — c'est à vous d'en reprendre les informations utiles pour
|
||||
créer l'investissement manuellement, l'outil ne saisit rien lui-même.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Exemples de questions</h4>
|
||||
<ul style={{ margin: '0 0 12px 16px', paddingLeft: 0, lineHeight: 2 }}>
|
||||
<li>« Quel est mon encours de crowdlending actuellement ? »</li>
|
||||
<li>« Quel a été mon rendement (intérêts nets) en 2026 ? »</li>
|
||||
<li>« Liste-moi les investissements en retard ou en procédure. »</li>
|
||||
<li>« Donne-moi le détail de l'investissement 42, avec ses remboursements. »</li>
|
||||
<li>« Quels remboursements ai-je reçus entre le 1er et le 30 juin 2026 ? »</li>
|
||||
<li>« Quels ont été mes derniers dépôts et retraits ? »</li>
|
||||
<li>« Regarde cette annonce de projet et propose-moi les infos pour créer l'investissement : [URL] »
|
||||
(développement local, avec <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_fetch_url</code> activé)</li>
|
||||
</ul>
|
||||
<p style={{ marginBottom: 0 }}>
|
||||
Ces exemples fonctionnent aussi bien en dev qu'en prod dès lors que le serveur correspondant est connecté
|
||||
(voir les FAQ de configuration ci-dessus) — les six premiers outils sont identiques dans les deux environnements.
|
||||
</p>
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Comment interroger mon portefeuille depuis Excel avec Power Query ?" openQuestion={openQuestion} setOpenQuestion={setOpenQuestion}>
|
||||
<p style={{ marginTop: 0 }}>
|
||||
Comme le serveur MCP, Power Query s'appuie sur l'API v1 (lecture seule) via une clé API.
|
||||
Power Query est intégré nativement à Excel (365 / 2016 et plus, Windows ou Mac) — rien à
|
||||
installer. Chaque étape ci-dessous correspond à un écran que vous pouvez capturer pour
|
||||
illustrer votre propre guide.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<DownloadLink href="/powerquery/crowdlending-powerquery-exemple.xlsb">
|
||||
Télécharger le classeur complet (.xlsb)
|
||||
</DownloadLink>
|
||||
</div>
|
||||
<p style={{ marginTop: 0, marginBottom: 16 }}>
|
||||
Le classeur ci-dessus contient déjà les 7 requêtes ci-dessous, prêtes à charger — il ne
|
||||
reste qu'à renseigner votre clé (étape 1). Vous pouvez aussi tout reconstruire à la main
|
||||
avec les fichiers <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>.pq</code> individuels
|
||||
proposés à chaque étape ci-dessous.
|
||||
</p>
|
||||
|
||||
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Étape 1 — Générer une clé API</h4>
|
||||
<ol style={{ margin: '0 0 12px 16px', paddingLeft: 0, lineHeight: 1.8 }}>
|
||||
<li><strong style={{ color: 'var(--text)' }}>Mon compte → Clés API → Nouvelle clé</strong>.</li>
|
||||
<li>Donnez-lui un nom explicite (ex. « Excel »), validez, puis copiez immédiatement la clé
|
||||
affichée — elle ne sera plus visible en clair ensuite.</li>
|
||||
</ol>
|
||||
|
||||
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Étape 2 — Créer les deux requêtes de connexion</h4>
|
||||
<p>
|
||||
Dans Excel : <strong style={{ color: 'var(--text)' }}>Données → Obtenir des données → À partir d'autres
|
||||
sources → Requête vide</strong>. Une nouvelle requête « Requête1 » apparaît dans l'éditeur Power Query.
|
||||
</p>
|
||||
<ol style={{ margin: '0 0 8px 16px', paddingLeft: 0, lineHeight: 1.8 }}>
|
||||
<li>Renommez-la <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>ApiBaseUrl</code> (double-clic
|
||||
sur son nom dans le volet Requêtes), puis <strong style={{ color: 'var(--text)' }}>Affichage → Éditeur avancé</strong>,
|
||||
effacez le contenu par défaut et collez :</li>
|
||||
</ol>
|
||||
<pre style={{ margin: '0 0 12px', padding: '12px 16px', background: 'var(--surface-2)', borderRadius: 8, fontFamily: 'monospace', fontSize: 'var(--fs-sm)', color: 'var(--text)', whiteSpace: 'pre-wrap', overflowX: 'auto' }}>
|
||||
{`"http://localhost:4000/api/v1"`}
|
||||
</pre>
|
||||
<DownloadLink href="/powerquery/queries/ApiBaseUrl.pq">Télécharger ApiBaseUrl.pq</DownloadLink>
|
||||
<img
|
||||
src="/powerquery/apibaseurl-editeur-avance.png"
|
||||
alt="Éditeur avancé Power Query — requête ApiBaseUrl avec l'URL de l'API en production"
|
||||
style={{ width: '100%', maxWidth: 600, borderRadius: 8, border: '1px solid var(--border)', display: 'block', margin: '0 0 16px' }}
|
||||
onError={(e) => { e.currentTarget.style.display = 'none'; }}
|
||||
/>
|
||||
<ol start={2} style={{ margin: '0 0 8px 16px', paddingLeft: 0, lineHeight: 1.8 }}>
|
||||
<li>Cliquez <strong style={{ color: 'var(--text)' }}>Terminé</strong>, puis <strong style={{ color: 'var(--text)' }}>Accueil → Fermer et charger dans… → Uniquement créer la connexion</strong> (pas besoin d'un tableau pour celle-ci).</li>
|
||||
<li>Répétez l'opération pour une seconde requête vide nommée <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>ApiKey</code> :</li>
|
||||
</ol>
|
||||
<pre style={{ margin: '0 0 12px', padding: '12px 16px', background: 'var(--surface-2)', borderRadius: 8, fontFamily: 'monospace', fontSize: 'var(--fs-sm)', color: 'var(--text)', whiteSpace: 'pre-wrap', overflowX: 'auto' }}>
|
||||
{`"clk_live_VOTRE_CLE_API"`}
|
||||
</pre>
|
||||
<DownloadLink href="/powerquery/queries/ApiKey.pq">Télécharger ApiKey.pq</DownloadLink>
|
||||
<img
|
||||
src="/powerquery/apikey-editeur-avance.png"
|
||||
alt="Éditeur avancé Power Query — requête ApiKey avec la clé API renseignée"
|
||||
style={{ width: '100%', maxWidth: 600, borderRadius: 8, border: '1px solid var(--border)', display: 'block', margin: '0 0 16px' }}
|
||||
onError={(e) => { e.currentTarget.style.display = 'none'; }}
|
||||
/>
|
||||
<p>Remplacez par la clé copiée à l'étape 1, entre guillemets. Même chose : Terminé → Uniquement créer la connexion.</p>
|
||||
|
||||
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Étape 3 — La fonction technique fnApiGet</h4>
|
||||
<p>
|
||||
Une troisième requête vide, nommée <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>fnApiGet</code>,
|
||||
fait l'appel HTTP et transmet la clé dans l'en-tête <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>X-API-Key</code> —
|
||||
les requêtes suivantes la réutiliseront par son nom, sans la récrire :
|
||||
</p>
|
||||
<pre style={{ margin: '0 0 12px', padding: '12px 16px', background: 'var(--surface-2)', borderRadius: 8, fontFamily: 'monospace', fontSize: 'var(--fs-sm)', color: 'var(--text)', whiteSpace: 'pre-wrap', overflowX: 'auto' }}>
|
||||
{`(chemin as text, optional parametres as nullable record) as any =>
|
||||
let
|
||||
parametresBruts = if parametres = null then [] else parametres,
|
||||
champsUtiles = List.Select(
|
||||
Record.FieldNames(parametresBruts),
|
||||
each Record.Field(parametresBruts, _) <> null and Record.Field(parametresBruts, _) <> ""
|
||||
),
|
||||
parametresNettoyes = Record.SelectFields(parametresBruts, champsUtiles),
|
||||
reponse = Web.Contents(
|
||||
ApiBaseUrl,
|
||||
[
|
||||
RelativePath = chemin,
|
||||
Headers = [#"X-API-Key" = ApiKey, #"Accept" = "application/json"],
|
||||
Query = parametresNettoyes
|
||||
]
|
||||
),
|
||||
resultat = Json.Document(reponse)
|
||||
in
|
||||
resultat`}
|
||||
</pre>
|
||||
<DownloadLink href="/powerquery/queries/fnApiGet.pq">Télécharger fnApiGet.pq</DownloadLink>
|
||||
<img
|
||||
src="/powerquery/fnapiget-editeur-avance.png"
|
||||
alt="Éditeur avancé Power Query — fonction fnApiGet"
|
||||
style={{ width: '100%', maxWidth: 700, borderRadius: 8, border: '1px solid var(--border)', display: 'block', margin: '0 0 16px' }}
|
||||
onError={(e) => { e.currentTarget.style.display = 'none'; }}
|
||||
/>
|
||||
<p>Terminé → Fermer et charger dans… → Uniquement créer la connexion (ce n'est pas une donnée à afficher).</p>
|
||||
|
||||
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Étape 4 — Première requête de données : Investissements</h4>
|
||||
<p>Une nouvelle requête vide, nommée <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>Investissements</code> :</p>
|
||||
<pre style={{ margin: '0 0 12px', padding: '12px 16px', background: 'var(--surface-2)', borderRadius: 8, fontFamily: 'monospace', fontSize: 'var(--fs-sm)', color: 'var(--text)', whiteSpace: 'pre-wrap', overflowX: 'auto' }}>
|
||||
{`let
|
||||
// Pour filtrer par statut, remplacez null par [statut = "rembourse"]
|
||||
// Statuts possibles : en_cours, rembourse, en_retard, procedure, cloture
|
||||
Source = fnApiGet("/investissements", null),
|
||||
Resultat = Table.FromRecords(Source)
|
||||
in
|
||||
Resultat`}
|
||||
</pre>
|
||||
<DownloadLink href="/powerquery/queries/Investissements.pq">Télécharger Investissements.pq</DownloadLink>
|
||||
<img
|
||||
src="/powerquery/investissements-editeur-avance.png"
|
||||
alt="Éditeur avancé Power Query — requête Investissements avec aperçu des données chargées"
|
||||
style={{ width: '100%', maxWidth: 700, borderRadius: 8, border: '1px solid var(--border)', display: 'block', margin: '0 0 16px' }}
|
||||
onError={(e) => { e.currentTarget.style.display = 'none'; }}
|
||||
/>
|
||||
<p>
|
||||
Cette fois-ci, <strong style={{ color: 'var(--text)' }}>Terminé → Fermer et charger</strong> (le bouton
|
||||
simple, pas « … ») : la requête s'ajoute comme tableau dans une nouvelle feuille, avec vos
|
||||
investissements en ligne et en colonnes.
|
||||
</p>
|
||||
|
||||
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Étape 5 — Actualiser</h4>
|
||||
<p style={{ marginBottom: 0 }}>
|
||||
<strong style={{ color: 'var(--text)' }}>Données → Actualiser tout</strong> (ou Ctrl+Alt+F5) à tout moment
|
||||
pour recharger les données depuis l'API — utile après un nouvel investissement ou remboursement
|
||||
saisi dans l'app.
|
||||
</p>
|
||||
|
||||
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Aller plus loin — les autres requêtes</h4>
|
||||
<p>Même principe (requête vide → nom exact → éditeur avancé → coller → charger) pour :</p>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 'var(--fs-sm)', margin: '0 0 14px' }}>
|
||||
<tbody>
|
||||
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
|
||||
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>Investisseur</code>
|
||||
</td>
|
||||
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>Profil investisseur (ou liste des membres si clé « Famille et entreprises »).</td>
|
||||
<td style={{ padding: '8px 0 8px 10px', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
|
||||
<DownloadLink href="/powerquery/queries/Investisseur.pq">.pq</DownloadLink>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
|
||||
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>Dashboard</code>
|
||||
</td>
|
||||
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>KPIs du portefeuille, au format « Indicateur / Valeur » (facile à croiser dans un TCD).</td>
|
||||
<td style={{ padding: '8px 0 8px 10px', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
|
||||
<DownloadLink href="/powerquery/queries/Dashboard.pq">.pq</DownloadLink>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
|
||||
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>Remboursements</code>
|
||||
</td>
|
||||
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>Historique des remboursements, filtrable par période.</td>
|
||||
<td style={{ padding: '8px 0 8px 10px', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
|
||||
<DownloadLink href="/powerquery/queries/Remboursements.pq">.pq</DownloadLink>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
|
||||
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>DépôtsRetraits</code>
|
||||
</td>
|
||||
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>Historique des mouvements de cash.</td>
|
||||
<td style={{ padding: '8px 0 8px 10px', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
|
||||
<DownloadLink href="/powerquery/queries/DepotsRetraits.pq">.pq</DownloadLink>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
|
||||
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>fnDetailInvestissement</code>
|
||||
</td>
|
||||
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>
|
||||
Fonction avancée : sur la table <em>Investissements</em>, colonne personnalisée{' '}
|
||||
<code style={{ fontFamily: 'monospace' }}>= fnDetailInvestissement([id])</code>{' '}
|
||||
pour ramener le détail + les remboursements de chaque prêt.
|
||||
</td>
|
||||
<td style={{ padding: '8px 0 8px 10px', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
|
||||
<DownloadLink href="/powerquery/queries/fnDetailInvestissement.pq">.pq</DownloadLink>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>
|
||||
Toutes ces requêtes sont déjà incluses dans le classeur téléchargeable en haut de cette fiche —
|
||||
les fichiers <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>.pq</code> ci-dessus
|
||||
ne sont utiles que si vous préférez les coller vous-même dans un classeur existant.
|
||||
</p>
|
||||
|
||||
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Dépannage</h4>
|
||||
<ul style={{ margin: '0 0 12px 16px', paddingLeft: 0, lineHeight: 1.8 }}>
|
||||
<li><strong style={{ color: 'var(--text)' }}>« Impossible de se connecter au service distant »</strong> — le
|
||||
backend n'est pas démarré, ou <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>ApiBaseUrl</code> est
|
||||
incorrecte (vérifiez le port et le suffixe <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>/api/v1</code>).</li>
|
||||
<li><strong style={{ color: 'var(--text)' }}>Erreur 401 / clé invalide</strong> — régénérez une clé dans
|
||||
Mon compte → Clés API et mettez à jour la requête <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>ApiKey</code>.</li>
|
||||
<li><strong style={{ color: 'var(--text)' }}>Avertissement de confidentialité au premier chargement</strong> —
|
||||
normal pour toute nouvelle source Web dans Power Query ; choisissez « Organisationnel » ou « Public »
|
||||
selon votre contexte.</li>
|
||||
</ul>
|
||||
<p style={{ marginBottom: 0 }}>
|
||||
Comme le serveur MCP, ces requêtes sont strictement en lecture seule : aucune saisie n'est possible
|
||||
depuis Excel, toute modification reste à faire dans l'application.
|
||||
</p>
|
||||
</FaqItem>
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -483,6 +483,18 @@ export default function Dashboard() {
|
||||
<EmptyState />
|
||||
</>
|
||||
);
|
||||
if (platLoaded && data.portfolio.nb_projets === 0) return (
|
||||
<>
|
||||
<div className="topbar"><h2>Tableau de bord</h2></div>
|
||||
<EmptyState
|
||||
icon="📊"
|
||||
title="Aucun investissement pour le moment"
|
||||
message="Vos plateformes sont configurées, mais aucun investissement n'y a encore été ajouté. Ajoutez un investissement pour voir apparaître les données de votre tableau de bord."
|
||||
cta="Ajouter un investissement"
|
||||
to="/investissements?new=1"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
const { cash, cashByPlatform, portfolio, interets, interetsParAnnee } = data;
|
||||
const viewTitle = activeView === 'all'
|
||||
|
||||
@@ -12,9 +12,16 @@ import SoldeChart from '../components/SoldeChart.jsx';
|
||||
import DistributionChart from '../components/DistributionChart.jsx';
|
||||
import { fmtEUR, fmtDate, today } from '../utils/format.js';
|
||||
import DepotsMensuelTable from '../components/DepotsMensuelTable.jsx';
|
||||
import SuiviObjectifs from '../components/SuiviObjectifs.jsx';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
/* ── Helpers export ──────────────────────────────────────────── */
|
||||
/** Horodatage local AAAAMMJJ_HHmmss (jamais toISOString(), cf. décalage UTC) */
|
||||
function timestampSuffix() {
|
||||
const d = new Date();
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
||||
}
|
||||
function dlBlob(content, filename, type) {
|
||||
const blob = new Blob([content], { type });
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -60,11 +67,11 @@ function mouvToCSV(rows) {
|
||||
return BOM + [headers,...data].map(r => r.map(q).join(sep)).join('\r\n');
|
||||
}
|
||||
|
||||
function mouvToXLS(rows) {
|
||||
function mouvToXLS(rows, multiDetenteur) {
|
||||
const data = rows.map(r => ({
|
||||
'Date': r.date_operation,
|
||||
'Plateforme': r.plateforme_nom || '',
|
||||
'Détenteur': r.plateforme_detenteur_nom || '',
|
||||
...(multiDetenteur ? { 'Détenteur': r.plateforme_detenteur_nom || '' } : {}),
|
||||
'Type': r.type === 'depot' ? 'Dépôt' : 'Retrait',
|
||||
'Montant (€)': r.montant,
|
||||
}));
|
||||
@@ -375,20 +382,30 @@ export default function DepotsRetraits() {
|
||||
const [allRows, setAllRows] = useState([]);
|
||||
const [plats, setPlats] = useState([]);
|
||||
const [dashData, setDashData] = useState(null);
|
||||
const [filter, setFilter] = useState({ type: '', plateforme_ids: [], year: '', month: '' });
|
||||
// Année sélectionnée, partagée entre l'onglet Plateformes et le filtre "Année" de Mouvements
|
||||
const currentYear = String(new Date().getFullYear());
|
||||
const [filter, setFilter] = useState({ type: '', plateforme_ids: [], year: currentYear, month: '' });
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [form, setForm] = useState(empty);
|
||||
const [err, setErr] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedRow, setSelectedRow] = useState(null);
|
||||
const [drPlatYear, setDrPlatYear] = useState(String(new Date().getFullYear()));
|
||||
const [drPlatYear, setDrPlatYear] = useState(currentYear);
|
||||
const [allRemb, setAllRemb] = useState([]);
|
||||
const [allInv, setAllInv] = useState([]);
|
||||
|
||||
const [allCorrections, setAllCorrections] = useState([]);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(null);
|
||||
|
||||
/* Objectifs de versement annuel — pour enrichir le KPI "Diff. Dépôts vs Retraits" */
|
||||
const [objectifsKpi, setObjectifsKpi] = useState([]);
|
||||
useEffect(() => {
|
||||
api.get('/objectifs', { type: 'versement_annuel' })
|
||||
.then(r => setObjectifsKpi(Array.isArray(r) ? r : []))
|
||||
.catch(() => setObjectifsKpi([]));
|
||||
}, []);
|
||||
|
||||
/* Lecture des params de navigation depuis la page Plateformes */
|
||||
useEffect(() => {
|
||||
const p = new URLSearchParams(search);
|
||||
@@ -401,6 +418,7 @@ export default function DepotsRetraits() {
|
||||
const platIds = p.get('plat_ids');
|
||||
if (platIds) newFilter.plateforme_ids = platIds.split(',').filter(Boolean);
|
||||
setFilter(newFilter);
|
||||
setDrPlatYear(newFilter.year); // garde l'onglet Plateformes synchronisé avec l'année du drill-down
|
||||
navigate('/depots-retraits', { replace: true });
|
||||
}, []); // eslint-disable-line
|
||||
const [openMenu, setOpenMenu] = useState(null); // { row, x, y }
|
||||
@@ -672,6 +690,26 @@ export default function DepotsRetraits() {
|
||||
return balance;
|
||||
})();
|
||||
|
||||
/* ── Enrichissement du KPI "Diff. Dépôts vs Retraits" avec l'objectif de l'année ──
|
||||
Toujours calculé sur le portefeuille entier (ignore le filtre plateforme),
|
||||
pour l'année sélectionnée (drPlatYear, ou l'année en cours si "Toutes les années"). */
|
||||
const scopeInvestisseurIdsKpi = activeView === 'all' ? investisseurs.map(i => i.id) : [activeId];
|
||||
const kpiObjectifYear = Number(drPlatYear || new Date().getFullYear());
|
||||
const kpiObjectifTotal = (() => {
|
||||
const list = objectifsKpi.filter(o => o.annee === kpiObjectifYear && scopeInvestisseurIdsKpi.includes(o.investisseur_id));
|
||||
return list.length ? list.reduce((s, o) => s + o.montant, 0) : null;
|
||||
})();
|
||||
const kpiDiffGlobalYear = (() => {
|
||||
let d = 0, r = 0;
|
||||
for (const row of allRows) {
|
||||
if (!scopeInvestisseurIdsKpi.includes(row.investisseur_id)) continue;
|
||||
if (row.date_operation?.slice(0, 4) !== String(kpiObjectifYear)) continue;
|
||||
if (row.type === 'depot') d += row.montant; else r += row.montant;
|
||||
}
|
||||
return d - r;
|
||||
})();
|
||||
const kpiEcartObjectif = kpiObjectifTotal != null ? kpiDiffGlobalYear - kpiObjectifTotal : null;
|
||||
|
||||
const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1;
|
||||
|
||||
/** Investisseur par défaut selon la vue active */
|
||||
@@ -889,13 +927,25 @@ export default function DepotsRetraits() {
|
||||
<EmptyState />
|
||||
</>
|
||||
);
|
||||
if (!loading && !modalOpen && !corrModal.open && allRows.length === 0) return (
|
||||
<>
|
||||
<div className="topbar"><h2>Dépôts / Retraits</h2></div>
|
||||
<EmptyState
|
||||
icon="💶"
|
||||
title="Aucun dépôt enregistré"
|
||||
message="Vos plateformes sont configurées, mais aucun dépôt n'a encore été enregistré. Ajoutez un dépôt pour commencer à suivre votre solde."
|
||||
cta="Ajouter un dépôt"
|
||||
to="/depots-retraits?new=1"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div className="topbar">
|
||||
<h2><PageIcon name="depots-retraits" />Dépôts / Retraits</h2>
|
||||
{(filter.type || filter.plateforme_ids.length > 0 || filter.year || filter.month) && (
|
||||
{(filter.type || filter.plateforme_ids.length > 0 || filter.month) && (
|
||||
<button className="dr-clear-btn"
|
||||
onClick={() => setFilter({ type: '', plateforme_ids: [], year: '', month: '' })}>
|
||||
onClick={() => { setFilter({ type: '', plateforme_ids: [], year: '', month: '' }); setDrPlatYear(''); }}>
|
||||
✕ Effacer les filtres
|
||||
</button>
|
||||
)}
|
||||
@@ -934,10 +984,10 @@ export default function DepotsRetraits() {
|
||||
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.retraits)} en {prevYear}</div>
|
||||
</div>
|
||||
<div
|
||||
className={`kpi${(filter.type || filter.plateforme_ids.length > 0 || filter.year || filter.month) ? ' dr-kpi-clickable dr-kpi-active dr-kpi-active-neutral' : ''}`}
|
||||
onClick={() => setFilter({ type: '', plateforme_ids: [], year: '', month: '' })}
|
||||
title={(filter.type || filter.plateforme_ids.length > 0 || filter.year || filter.month) ? 'Effacer les filtres' : undefined}
|
||||
style={(filter.type || filter.plateforme_ids.length > 0 || filter.year || filter.month) ? { cursor: 'pointer' } : {}}
|
||||
className={`kpi${(filter.type || filter.plateforme_ids.length > 0 || filter.month) ? ' dr-kpi-clickable dr-kpi-active dr-kpi-active-neutral' : ''}`}
|
||||
onClick={() => { setFilter({ type: '', plateforme_ids: [], year: '', month: '' }); setDrPlatYear(''); }}
|
||||
title={(filter.type || filter.plateforme_ids.length > 0 || filter.month) ? 'Effacer les filtres' : undefined}
|
||||
style={(filter.type || filter.plateforme_ids.length > 0 || filter.month) ? { cursor: 'pointer' } : {}}
|
||||
>
|
||||
<div className="label">Diff. Dépôts vs Retraits</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
|
||||
@@ -945,6 +995,16 @@ export default function DepotsRetraits() {
|
||||
<TrendBadge current={totals.depots - totals.retraits} prev={prevTotals.depots - prevTotals.retraits} />
|
||||
</div>
|
||||
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.depots - prevTotals.retraits)} en {prevYear}</div>
|
||||
{kpiEcartObjectif != null && (
|
||||
<div style={{
|
||||
fontSize: '0.8em', marginTop: 4, fontWeight: 600,
|
||||
color: kpiEcartObjectif >= 0 ? 'var(--success)' : 'var(--warning)',
|
||||
}}>
|
||||
{kpiEcartObjectif >= 0
|
||||
? `+${fmtEUR(kpiEcartObjectif)} au-delà de l'objectif ${kpiObjectifYear}`
|
||||
: `Reste ${fmtEUR(-kpiEcartObjectif)} pour l'objectif ${kpiObjectifYear}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="kpi" title="Cash disponible sur les porte-monnaie des plateformes (hors compte courant)">
|
||||
<div className="label">Porte-monnaie</div>
|
||||
@@ -960,12 +1020,25 @@ export default function DepotsRetraits() {
|
||||
{!listFocused && <div className="dr-tabs">
|
||||
<button className={`dr-tab${activeTab === 'plateformes' ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab('plateformes')}>Plateformes</button>
|
||||
<button className={`dr-tab${activeTab === 'vision-annuelle' ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab('vision-annuelle')}>Vision annuelle</button>
|
||||
<button className={`dr-tab${activeTab === 'vision-mensuelle' ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab('vision-mensuelle')}>Vision mensuelle</button>
|
||||
<button className={`dr-tab${activeTab === 'mouvements' ? ' active' : ''}`}
|
||||
onClick={() => { setActiveTab('mouvements'); setSelectedRow(r => r ?? (rows[0] || null)); }}>Mouvements</button>
|
||||
</div>}
|
||||
|
||||
{/* ====== ONGLET VISION ANNUELLE ====== */}
|
||||
{activeTab === 'vision-annuelle' && (
|
||||
<div style={{ padding: '0 24px' }}>
|
||||
<SuiviObjectifs
|
||||
rows={allRows}
|
||||
investisseurs={investisseurs}
|
||||
scopeInvestisseurIds={scopeInvestisseurIdsKpi}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ====== ONGLET PLATEFORMES ====== */}
|
||||
{activeTab === 'plateformes' && (
|
||||
<div style={{ padding: '0 24px' }}>
|
||||
@@ -976,7 +1049,7 @@ export default function DepotsRetraits() {
|
||||
{drPlatYear && <span style={{ marginLeft: 8 }}>pour l'année {drPlatYear}</span>}
|
||||
</h3>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<select value={drPlatYear} onChange={e => setDrPlatYear(e.target.value)}
|
||||
<select value={drPlatYear} onChange={e => { const v = e.target.value; setDrPlatYear(v); setFilter(f => ({ ...f, year: v })); }}
|
||||
style={{
|
||||
fontSize: 'var(--fs-xs)', padding: '4px 8px', height: 30,
|
||||
borderRadius: 6, border: '1px solid var(--border)',
|
||||
@@ -1017,11 +1090,11 @@ export default function DepotsRetraits() {
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Plateforme</th><th>Détenteur</th><th className="num">Dépôts</th><th className="num">Retraits</th><th className="num">Diff. Dépôts vs Retraits</th><th className="num">Porte-monnaie</th><th className="num">Poids</th><th style={{ width: 28 }}></th></tr>
|
||||
<tr><th>Plateforme</th>{multiDetenteur && <th>Détenteur</th>}<th className="num">Dépôts</th><th className="num">Retraits</th><th className="num">Diff. Dépôts vs Retraits</th><th className="num">Porte-monnaie</th><th className="num">Poids</th><th style={{ width: 28 }}></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{drPlatData.length === 0 && (
|
||||
<tr><td colSpan={8} className="text-muted" style={{ textAlign: 'center' }}>Aucune plateforme</td></tr>
|
||||
<tr><td colSpan={multiDetenteur ? 8 : 7} className="text-muted" style={{ textAlign: 'center' }}>Aucune plateforme</td></tr>
|
||||
)}
|
||||
{(() => {
|
||||
const totalSolde = drPlatTotals.solde_net;
|
||||
@@ -1041,7 +1114,7 @@ export default function DepotsRetraits() {
|
||||
<td>
|
||||
<span style={{ fontWeight: isActive ? 600 : undefined }}>{p.nom}</span>
|
||||
</td>
|
||||
<td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{p.detenteur_nom || '—'}</td>
|
||||
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{p.detenteur_nom || '—'}</td>}
|
||||
<td className="num">{fmtEUR(p.depots)}</td>
|
||||
<td className="num">{fmtEUR(p.retraits)}</td>
|
||||
<td className="num">{fmtEUR(p.solde_net)}</td>
|
||||
@@ -1068,7 +1141,7 @@ export default function DepotsRetraits() {
|
||||
<tfoot>
|
||||
<tr style={{ borderTop: '2px solid var(--border)', fontWeight: 700, background: 'var(--surface-2)' }}>
|
||||
<td>Total — {drPlatData.length} plateformes</td>
|
||||
<td />
|
||||
{multiDetenteur && <td />}
|
||||
<td className="num">{fmtEUR(drPlatTotals.depots)}</td>
|
||||
<td className="num">{fmtEUR(drPlatTotals.retraits)}</td>
|
||||
<td className="num">{fmtEUR(drPlatTotals.solde_net)}</td>
|
||||
@@ -1142,10 +1215,20 @@ export default function DepotsRetraits() {
|
||||
</button>
|
||||
<ExportDropdown
|
||||
disabled={rows.length === 0}
|
||||
onCSV={() => dlBlob(mouvToCSV(rows), 'mouvements.csv', 'text/csv;charset=utf-8')}
|
||||
onXLS={() => dlBlob(mouvToXLS(rows), 'mouvements.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')}
|
||||
onJSON={() => dlBlob(mouvToJSON(rows), 'mouvements.json', 'application/json')}
|
||||
onCSV={() => dlBlob(mouvToCSV(rows), `Mouvements Dépôts Retraits ${timestampSuffix()}.csv`, 'text/csv;charset=utf-8')}
|
||||
onXLS={() => dlBlob(mouvToXLS(rows, multiDetenteur), `Mouvements Dépôts Retraits ${timestampSuffix()}.xlsx`, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')}
|
||||
onJSON={() => dlBlob(mouvToJSON(rows), `Mouvements Dépôts Retraits ${timestampSuffix()}.json`, 'application/json')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-btn"
|
||||
title="Importer des dépôts/retraits"
|
||||
onClick={() => navigate('/settings?section=imports&module=depots_retraits')}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1205,7 +1288,7 @@ export default function DepotsRetraits() {
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 100 }}>
|
||||
<label>Année</label>
|
||||
<select value={filter.year} onChange={e => setFilter({ ...filter, year: e.target.value })}>
|
||||
<select value={filter.year} onChange={e => { const v = e.target.value; setFilter(f => ({ ...f, year: v })); setDrPlatYear(v); }}>
|
||||
<option value="">Toutes</option>
|
||||
{years.map(y => <option key={y} value={y}>{y}</option>)}
|
||||
</select>
|
||||
@@ -1224,12 +1307,12 @@ export default function DepotsRetraits() {
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th><th>Plateforme</th><th>Détenteur</th><th>Type de mouvement</th><th className="num">Montant</th><th style={{ width: 36 }}></th>
|
||||
<th>Date</th><th>Plateforme</th>{multiDetenteur && <th>Détenteur</th>}<th>Type de mouvement</th><th className="num">Montant</th><th style={{ width: 36 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tableRows.length === 0 && (
|
||||
<tr><td colSpan={5} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
|
||||
<tr><td colSpan={multiDetenteur ? 6 : 5} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
|
||||
{loading ? 'Chargement…' : 'Aucun mouvement'}
|
||||
</td></tr>
|
||||
)}
|
||||
@@ -1243,7 +1326,7 @@ export default function DepotsRetraits() {
|
||||
style={{ cursor: 'pointer' }}>
|
||||
<td>{fmtDate(r.date_operation)}</td>
|
||||
<td>{r.plateforme_nom}</td>
|
||||
<td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{r.plateforme_detenteur_nom || '—'}</td>
|
||||
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{r.plateforme_detenteur_nom || '—'}</td>}
|
||||
<td>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
@@ -1272,7 +1355,7 @@ export default function DepotsRetraits() {
|
||||
style={{ cursor: 'pointer', opacity: isAuto ? 0.8 : 1 }}>
|
||||
<td>{fmtDate(r.date_operation)}</td>
|
||||
<td>{r.plateforme_nom}</td>
|
||||
<td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{r.plateforme_detenteur_nom || '—'}</td>
|
||||
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{r.plateforme_detenteur_nom || '—'}</td>}
|
||||
<td>
|
||||
<span className={`badge ${r.type}`}>{r.type}</span>
|
||||
{isAuto && (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -779,6 +779,18 @@ export default function Investissements() {
|
||||
<EmptyState />
|
||||
</>
|
||||
);
|
||||
if (!loading && !modalOpen && allRows.length === 0) return (
|
||||
<>
|
||||
<div className="topbar"><h2>Investissements</h2></div>
|
||||
<EmptyState
|
||||
icon="📊"
|
||||
title="Aucun investissement pour le moment"
|
||||
message="Vos plateformes sont configurées, mais aucun investissement n'y a encore été ajouté. Ajoutez votre premier investissement pour commencer le suivi."
|
||||
cta="Ajouter un investissement"
|
||||
to="/investissements?new=1"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div className="topbar">
|
||||
@@ -950,7 +962,7 @@ export default function Investissements() {
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Plateforme</th>
|
||||
<th>Détenteur</th>
|
||||
{multiDetenteur && <th>Détenteur</th>}
|
||||
<th className="num">Projets</th>
|
||||
<th className="num">Montant investi</th>
|
||||
<th className="num">Capital investi</th>
|
||||
@@ -962,7 +974,7 @@ export default function Investissements() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{platData.length === 0 && (
|
||||
<tr><td colSpan={9} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
|
||||
<tr><td colSpan={multiDetenteur ? 9 : 8} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
|
||||
Aucun investissement
|
||||
</td></tr>
|
||||
)}
|
||||
@@ -987,7 +999,7 @@ export default function Investissements() {
|
||||
}}>!</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{p.detenteur_nom || '—'}</td>
|
||||
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{p.detenteur_nom || '—'}</td>}
|
||||
<td className="num">{p.count}</td>
|
||||
<td className="num">{fmtEUR(p.investi)}</td>
|
||||
<td className="num">{fmtEUR(p.encours)}</td>
|
||||
@@ -1020,7 +1032,7 @@ export default function Investissements() {
|
||||
<td style={{ padding: '10px 12px', color: 'var(--text-muted)', fontSize: 'var(--fs-sm)', fontWeight: 600 }}>
|
||||
Total — {platData.length} plateformes
|
||||
</td>
|
||||
<td />
|
||||
{multiDetenteur && <td />}
|
||||
<td className="num" style={{ padding: '10px 12px' }}>{platTotals.count}</td>
|
||||
<td className="num" style={{ padding: '10px 12px' }}>{fmtEUR(platTotals.investi)}</td>
|
||||
<td className="num" style={{ padding: '10px 12px' }}>{fmtEUR(platTotals.encours)}</td>
|
||||
@@ -1190,7 +1202,7 @@ export default function Investissements() {
|
||||
<table style={{ cursor: 'pointer' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th><th>Projet</th><th>Plateforme</th><th>Détenteur</th>
|
||||
<th>Date</th><th>Projet</th><th>Plateforme</th>{multiDetenteur && <th>Détenteur</th>}
|
||||
<th className="num">Montant</th><th className="num">Taux</th>
|
||||
<th className="num">Durée</th><th>Type remb.</th>
|
||||
<th className="num">Inv. remboursés</th><th className="num">Cap. restant dû</th>
|
||||
@@ -1201,7 +1213,7 @@ export default function Investissements() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 && (
|
||||
<tr><td colSpan={13} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
|
||||
<tr><td colSpan={multiDetenteur ? 13 : 12} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
|
||||
{loading ? 'Chargement…' : 'Aucun investissement'}
|
||||
</td></tr>
|
||||
)}
|
||||
@@ -1221,7 +1233,7 @@ export default function Investissements() {
|
||||
{r.emetteur && <div className="text-muted" style={{ fontSize: 11 }}>{r.emetteur}</div>}
|
||||
</td>
|
||||
<td>{r.plateforme_nom}</td>
|
||||
<td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{r.plateforme_detenteur_nom || '—'}</td>
|
||||
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{r.plateforme_detenteur_nom || '—'}</td>}
|
||||
<td className="num">{fmtEUR(capInv)}</td>
|
||||
<td className="num">{r.taux_interet != null ? fmtPct(r.taux_interet) : '—'}</td>
|
||||
<td className="num">{r.duree_mois ? `${r.duree_mois} m.` : '—'}</td>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext.jsx';
|
||||
import { api } from '../api.js';
|
||||
import AuthBgCol from '../components/AuthBgCol.jsx';
|
||||
@@ -28,6 +28,8 @@ function AppHeader({ appInfo }) {
|
||||
export default function Login() {
|
||||
const { login, completeLogin } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const accountDeleted = searchParams.get('deleted') === '1';
|
||||
|
||||
const [appInfo, setAppInfo] = useState({ appName: 'Crowdlending Tracker', iconUrl: null, allowRegistration: true });
|
||||
|
||||
@@ -193,6 +195,12 @@ export default function Login() {
|
||||
<form onSubmit={submit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<ErrBox msg={err} />
|
||||
|
||||
{accountDeleted && !err && (
|
||||
<div style={{ padding: '10px 14px', borderRadius: 8, fontSize: 14, background: 'var(--success-bg, #f0fdf4)', color: 'var(--success, #16a34a)', border: '1px solid #bbf7d0' }}>
|
||||
Votre compte a été supprimé avec succès.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{unverified && (
|
||||
<div style={{ padding: '12px 14px', borderRadius: 8, fontSize: 13, background: 'var(--warning-bg, #fffbeb)', color: 'var(--warning-text, #92400e)', border: '1px solid var(--warning-border, #fcd34d)', lineHeight: 1.5 }}>
|
||||
<strong>Email non vérifié.</strong> Vérifiez votre boîte mail et cliquez sur le lien reçu.
|
||||
|
||||
@@ -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 = [
|
||||
@@ -713,6 +726,730 @@ function TrustedDevicesSection() {
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Suppression définitive du compte ────────────────────────── */
|
||||
function DeleteAccountSection() {
|
||||
const { deleteAccount } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [password, setPassword] = useState('');
|
||||
const [err, setErr] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
setErr(null); setBusy(true);
|
||||
try {
|
||||
await deleteAccount(password);
|
||||
navigate('/login?deleted=1', { replace: true });
|
||||
} catch (e) {
|
||||
setErr(e.message || 'Une erreur est survenue.');
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card" style={{ marginTop: 20, border: '1px solid var(--danger,#dc2626)' }}>
|
||||
<h3 style={{ margin: '0 0 4px', color: 'var(--danger,#dc2626)' }}>Supprimer mon compte</h3>
|
||||
<p className="text-muted" style={{ margin: '0 0 16px', fontSize: 'var(--fs-sm)' }}>
|
||||
Cette action est <strong>définitive et irréversible</strong>. Toutes vos données seront immédiatement
|
||||
supprimées : plateformes, investissements, remboursements, dépôts/retraits, comptes courants,
|
||||
préférences et historique. Il ne sera pas possible de les récupérer.
|
||||
</p>
|
||||
|
||||
{err && <div className="error" style={{ marginBottom: 12 }}>{err}</div>}
|
||||
|
||||
{!confirming && (
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button type="button" className="danger"
|
||||
onClick={() => setConfirming(true)}>
|
||||
Supprimer mon compte
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirming && (
|
||||
<form onSubmit={submit} style={{
|
||||
marginTop: 16, paddingTop: 16, borderTop: '1px solid var(--border)',
|
||||
display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 400,
|
||||
}}>
|
||||
<p style={{ margin: 0, fontSize: 13, color: 'var(--text-muted)' }}>
|
||||
Confirmez votre mot de passe pour supprimer définitivement votre compte.
|
||||
</p>
|
||||
<div>
|
||||
<label>Mot de passe actuel</label>
|
||||
<PasswordInput required autoComplete="current-password" placeholder="••••••••"
|
||||
value={password} onChange={e => setPassword(e.target.value)} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 12 }}>
|
||||
<button type="submit" className="danger"
|
||||
disabled={busy || !password}>
|
||||
{busy ? 'Suppression…' : 'Confirmer la suppression définitive'}
|
||||
</button>
|
||||
<button type="button" onClick={() => { setConfirming(false); setPassword(''); setErr(null); }}
|
||||
style={{ background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: 13, cursor: 'pointer', textDecoration: 'underline', padding: 0 }}>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 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><URL_DE_VOTRE_API></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 && npm install && 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 où
|
||||
<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, où 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 }}>Où 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();
|
||||
@@ -725,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 (
|
||||
@@ -747,8 +1486,10 @@ export default function MonCompte() {
|
||||
{/* ── Contenu ─────────────────────────────────────── */}
|
||||
<div className="account-content account-content-center">
|
||||
<div className="account-content-narrow">
|
||||
{section === 'profil' && <AccountForm />}
|
||||
{section === 'profil' && <><AccountForm /><DeleteAccountSection /></>}
|
||||
{section === 'securite' && <><SecurityForm /><TwoFASection user={user} /><TrustedDevicesSection /></>}
|
||||
{section === 'api-keys' && <ApiKeysSection />}
|
||||
{section === 'mcp' && <McpServerSection goToApiKeys={() => setSection('api-keys')} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@ import PageIcon from '../components/PageIcon.jsx';
|
||||
import EmptyState from '../components/EmptyState.jsx';
|
||||
import InvChart from '../components/InvChart.jsx';
|
||||
import InvMensuelTable from '../components/InvMensuelTable.jsx';
|
||||
import { fmtEUR, fmtDate, fmtStatut, memberLabel } from '../utils/format.js';
|
||||
import SuiviObjectifs from '../components/SuiviObjectifs.jsx';
|
||||
import { fmtEUR, fmtDate, fmtStatut, memberLabel, today } from '../utils/format.js';
|
||||
import { xirr } from '../utils/xirr.js';
|
||||
import { usePagination } from '../hooks/usePagination.js';
|
||||
import Pagination from '../components/Pagination.jsx';
|
||||
|
||||
@@ -424,6 +426,9 @@ export default function Plateformes() {
|
||||
];
|
||||
}, [platRows, investisseurs]);
|
||||
|
||||
/* ── Vrai/faux multi-détenteur global (indépendant de la plateforme sélectionnée) ── */
|
||||
const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1;
|
||||
|
||||
/* ── Reset détenteur quand plateforme change (skip restauration initiale) ── */
|
||||
const isFirstPlatChange = useRef(true);
|
||||
useEffect(() => {
|
||||
@@ -594,16 +599,76 @@ export default function Plateformes() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedYear, filteredRows, allRembs, allReinvests, lastRembDateMap]);
|
||||
|
||||
/* ── XIRR agrégé (flux datés : investis + réinvestissements + remboursements + valorisation
|
||||
du capital restant 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;
|
||||
|
||||
if (!loading && plats.length === 0) {
|
||||
return (
|
||||
<>
|
||||
<div className="topbar">
|
||||
<h2><PageIcon name="plateforme" />Plateformes</h2>
|
||||
</div>
|
||||
<EmptyState />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!loading && platOptions.length === 0) {
|
||||
return (
|
||||
<>
|
||||
<div className="topbar">
|
||||
<h2><PageIcon name="plateforme" />Plateformes</h2>
|
||||
</div>
|
||||
<EmptyState />
|
||||
<EmptyState
|
||||
icon="📊"
|
||||
title="Aucun investissement pour le moment"
|
||||
message="Vos plateformes sont configurées, mais aucun investissement n'y a encore été ajouté. Ajoutez un investissement pour voir apparaître les données de vos plateformes."
|
||||
cta="Ajouter un investissement"
|
||||
to="/investissements?new=1"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -648,7 +713,7 @@ export default function Plateformes() {
|
||||
</div>}
|
||||
|
||||
{/* ── KPIs ── */}
|
||||
{!listFocused && <div className="dr-kpi-row" style={{ gridTemplateColumns: 'repeat(5, 1fr)' }}>
|
||||
{!listFocused && <div className="dr-kpi-row" style={{ gridTemplateColumns: 'repeat(6, 1fr)' }}>
|
||||
|
||||
{/* 1 — Capital investi */}
|
||||
<div className="kpi">
|
||||
@@ -716,6 +781,50 @@ export default function Plateformes() {
|
||||
{fmtEUR(netMode ? prevTotals.int_perc_net : prevTotals.int_perc)} en {prevYearLabel}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 6 — XIRR */}
|
||||
{(() => {
|
||||
const rendement = netMode ? plateformeXirr.net : plateformeXirr.brut;
|
||||
const cutoffLabel = selectedYear ? `31/12/${selectedYear}` : "aujourd'hui";
|
||||
const xirrExplication =
|
||||
`XIRR (taux de rendement interne actualisé) : taux annualisé calculé à partir de l'ensemble des flux réels datés ` +
|
||||
`des prêts de cette sélection (versements initiaux, réinvestissements, remboursements ${netMode ? 'nets, après fiscalité' : 'bruts, avant fiscalité'}). ` +
|
||||
`Chaque flux est pondéré par sa date exacte.\n\n` +
|
||||
(plateformeXirr.estime
|
||||
? `Certains prêts ne sont pas encore soldés : leur capital restant dû est ajouté en flux final, valorisé au ${cutoffLabel} — c'est donc une estimation, pas un taux définitif.`
|
||||
: 'Tous les prêts de cette sélection sont soldés : taux définitif.');
|
||||
return (
|
||||
<div className="kpi">
|
||||
<div className="label" style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<span>
|
||||
XIRR — {netMode ? 'Net' : 'Brut'}
|
||||
{plateformeXirr.estime && rendement !== null ? <span className="text-muted"> (estimé)</span> : ''}
|
||||
</span>
|
||||
<span
|
||||
className="cell-tooltip tooltip-down"
|
||||
data-tooltip={xirrExplication}
|
||||
style={{ display: 'inline-flex', color: 'var(--text-muted)' }}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 0' }}>
|
||||
<span
|
||||
style={{ fontSize: '1.35rem', fontWeight: 700, color: rendement !== null ? (rendement >= 0 ? 'var(--success)' : 'var(--danger)') : 'var(--text-muted)' }}
|
||||
>
|
||||
{rendement !== null
|
||||
? `${(rendement * 100).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} %`
|
||||
: 'Aucun remboursement'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>
|
||||
Valorisé au {cutoffLabel}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>}
|
||||
|
||||
{/* ── Onglets ── */}
|
||||
@@ -1402,6 +1511,14 @@ export default function Plateformes() {
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Suivi des objectifs de versement — toujours sur le portefeuille entier,
|
||||
indépendamment du filtre plateforme de cette page (cf. décision produit). */}
|
||||
<SuiviObjectifs
|
||||
rows={allDepots}
|
||||
investisseurs={investisseurs}
|
||||
scopeInvestisseurIds={activeView === 'all' ? investisseurs.map(i => i.id) : [activeId]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1517,7 +1634,7 @@ export default function Plateformes() {
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Projet</th>
|
||||
<th>Détenteur</th>
|
||||
{multiDetenteur && <th>Détenteur</th>}
|
||||
<th className="num">Date de souscription</th>
|
||||
<th className="num">Date cible</th>
|
||||
<th className="num">Montant</th>
|
||||
@@ -1538,7 +1655,7 @@ export default function Plateformes() {
|
||||
<tr key={r.id} style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/investissements/${r.id}`, { state: { from: { path: location.pathname, search: location.search, label: 'Plateformes' } } })}>
|
||||
<td style={{ fontWeight: 500 }}>{r.nom_projet}</td>
|
||||
<td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{(() => { const inv = investisseurs.find(i => i.id === r.investisseur_id); return inv ? memberLabel(inv) : '—'; })()}</td>
|
||||
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{(() => { const inv = investisseurs.find(i => i.id === r.investisseur_id); return inv ? memberLabel(inv) : '—'; })()}</td>}
|
||||
<td className="num" style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>{fmtDate(r.date_souscription)}</td>
|
||||
<td className="num" style={{ fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>{fmtDate(r.date_cible)}</td>
|
||||
<td className="num">{fmtEUR(r.montant_investi)}</td>
|
||||
|
||||
@@ -768,6 +768,18 @@ export default function Remboursements() {
|
||||
<EmptyState />
|
||||
</>
|
||||
);
|
||||
if (platLoaded && !loading && !modalOpen && allRows.length === 0) return (
|
||||
<>
|
||||
<div className="topbar"><h2>Remboursements</h2></div>
|
||||
<EmptyState
|
||||
icon="📋"
|
||||
title="Aucun remboursement pour le moment"
|
||||
message="Vos plateformes sont configurées, mais aucun remboursement n'a encore été enregistré. Ajoutez un remboursement pour voir apparaître vos données."
|
||||
cta="Ajouter un remboursement"
|
||||
to="/remboursements?new=1"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div className="topbar">
|
||||
@@ -887,7 +899,7 @@ export default function Remboursements() {
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Plateforme</th>
|
||||
<th>Détenteur</th>
|
||||
{multiDetenteur && <th>Détenteur</th>}
|
||||
<th className="num">Capital remboursé</th>
|
||||
<th className="num">Cashback</th>
|
||||
<th className="num">Intérêts ({netMode ? 'Net' : 'Brut'})</th>
|
||||
@@ -899,7 +911,7 @@ export default function Remboursements() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{byPlatform.length === 0 && (
|
||||
<tr><td colSpan={9} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
|
||||
<tr><td colSpan={multiDetenteur ? 9 : 8} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
|
||||
{loading ? 'Chargement…' : 'Aucun remboursement'}
|
||||
</td></tr>
|
||||
)}
|
||||
@@ -915,7 +927,7 @@ export default function Remboursements() {
|
||||
title={isActive ? 'Cliquer pour retirer le filtre' : 'Cliquer pour filtrer sur cette plateforme'}
|
||||
>
|
||||
<td><span style={{ fontWeight: isActive ? 600 : undefined }}>{p.nom}</span></td>
|
||||
<td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{p.detenteur_nom || '—'}</td>
|
||||
{multiDetenteur && <td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>{p.detenteur_nom || '—'}</td>}
|
||||
<td className="num">{fmtEUR(p.capital)}</td>
|
||||
<td className="num">{p.cashback > 0 ? fmtEUR(p.cashback) : '—'}</td>
|
||||
<td className="num">{fmtEUR(val)}</td>
|
||||
@@ -942,7 +954,7 @@ export default function Remboursements() {
|
||||
<tfoot>
|
||||
<tr style={{ borderTop: '2px solid var(--border)', fontWeight: 700, background: 'var(--surface-2)' }}>
|
||||
<td>Total — {byPlatform.length} plateformes</td>
|
||||
<td />
|
||||
{multiDetenteur && <td />}
|
||||
<td className="num">{fmtEUR(byPlatformTotals.capital)}</td>
|
||||
<td className="num">{byPlatformTotals.cashback > 0 ? fmtEUR(byPlatformTotals.cashback) : '—'}</td>
|
||||
<td className="num">{fmtEUR(netMode ? byPlatformTotals.interets_nets : byPlatformTotals.interets_bruts)}</td>
|
||||
@@ -1119,7 +1131,7 @@ export default function Remboursements() {
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th><th>Plateforme</th><th>Détenteur</th><th>Nom du projet</th><th>Versement</th>
|
||||
<th>Date</th><th>Plateforme</th>{multiDetenteur && <th>Détenteur</th>}<th>Nom du projet</th><th>Versement</th>
|
||||
<th className="num">Capital Remboursé</th><th className="num">Capital Restant dû</th>
|
||||
<th className="num">Cashback</th>
|
||||
<th className="num">Intérêts Bruts</th>
|
||||
@@ -1131,7 +1143,7 @@ export default function Remboursements() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{tableRows.length === 0 && (
|
||||
<tr><td colSpan={14} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
|
||||
<tr><td colSpan={multiDetenteur ? 14 : 13} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>
|
||||
{loading ? 'Chargement…' : 'Aucun remboursement'}
|
||||
</td></tr>
|
||||
)}
|
||||
@@ -1157,9 +1169,11 @@ export default function Remboursements() {
|
||||
}}>
|
||||
<td>{fmtDate(r.date_remb)}</td>
|
||||
<td>{r.plateforme_nom}</td>
|
||||
<td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>
|
||||
{isCorrection ? (r.investisseur_nom || '—') : (r.plateforme_detenteur_nom || '—')}
|
||||
</td>
|
||||
{multiDetenteur && (
|
||||
<td className="text-muted" style={{ fontSize: 'var(--fs-sm)' }}>
|
||||
{isCorrection ? (r.investisseur_nom || '—') : (r.plateforme_detenteur_nom || '—')}
|
||||
</td>
|
||||
)}
|
||||
<td>
|
||||
{isCorrection
|
||||
? <span style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>{r.nom_projet}</span>
|
||||
@@ -1272,7 +1286,7 @@ export default function Remboursements() {
|
||||
{simulMsg}
|
||||
</div>
|
||||
)}
|
||||
{simulInv && (!simulInv.taux_interet || !simulInv.duree_mois) && (
|
||||
{simulInv && (simulInv.taux_interet == null || !simulInv.duree_mois) && (
|
||||
<div className="error" style={{ marginTop: 12 }}>
|
||||
Cet investissement n'a pas de <strong>taux</strong> et/ou de <strong>durée</strong>. Renseignez-les dans la fiche.
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -156,6 +156,12 @@ export default function TaxReport() {
|
||||
const [activeTab, setActiveTab] = useState('2042');
|
||||
const [detailExpanded, setDetailExpanded] = useState(false);
|
||||
const [cerfaExpanded, setCerfaExpanded] = useState(false);
|
||||
const [hasPlateformes, setHasPlateformes] = useState(true); // true par défaut pour éviter un flash du mauvais message
|
||||
|
||||
/* ── Plateformes configurées ? (pour distinguer "pas de plateforme" de "pas encore de données") ── */
|
||||
useEffect(() => {
|
||||
api.get('/plateformes').then(p => setHasPlateformes((p || []).length > 0)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
/* ── Chargement des années disponibles ── */
|
||||
useEffect(() => {
|
||||
@@ -253,11 +259,21 @@ export default function TaxReport() {
|
||||
if (!loading && data && availableYears.length === 0) return (
|
||||
<>
|
||||
<div className="topbar"><h2>Fiscalité</h2></div>
|
||||
<EmptyState
|
||||
icon="📋"
|
||||
title="Aucune donnée fiscale disponible"
|
||||
message="Vous n'avez pas encore de données fiscales à déclarer. Ajoutez des plateformes et des remboursements pour voir apparaître votre récapitulatif fiscal."
|
||||
/>
|
||||
{hasPlateformes ? (
|
||||
<EmptyState
|
||||
icon="📋"
|
||||
title="Aucune donnée fiscale pour le moment"
|
||||
message="Vos plateformes sont configurées, mais aucun remboursement n'a encore été enregistré. Ajoutez un remboursement pour voir apparaître votre récapitulatif fiscal."
|
||||
cta="Ajouter un remboursement"
|
||||
to="/remboursements?new=1"
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon="📋"
|
||||
title="Aucune donnée fiscale disponible"
|
||||
message="Vous n'avez pas encore de données fiscales à déclarer. Ajoutez des plateformes et des remboursements pour voir apparaître votre récapitulatif fiscal."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
|
||||
@@ -12,39 +12,59 @@ const CATEGORY_META = {
|
||||
invitation: { label: 'Invitation', color: '#6366f1' },
|
||||
};
|
||||
|
||||
const ACTION_LABELS = {
|
||||
login_success: 'Connexion réussie',
|
||||
login_failed: 'Échec de connexion',
|
||||
login_2fa_success: 'Connexion 2FA réussie',
|
||||
user_registered: 'Auto-inscription',
|
||||
user_created: 'Compte créé (admin)',
|
||||
user_deleted: 'Compte supprimé',
|
||||
email_verified_admin:'Email vérifié (admin)',
|
||||
role_changed: 'Rôle modifié',
|
||||
status_changed: 'Statut modifié',
|
||||
'2fa_enabled': '2FA activé',
|
||||
'2fa_disabled': '2FA désactivé',
|
||||
invitation_sent: 'Invitation envoyée',
|
||||
invitation_accepted: 'Invitation acceptée',
|
||||
// ── Statut succès / avertissement / échec par action ───────────────────────
|
||||
// IMPORTANT : garder synchronisé avec STATUS_ACTIONS dans backend/src/routes/auditLogs.js
|
||||
const ACTION_STATUS = {
|
||||
login_success: 'success',
|
||||
login_2fa_success: 'success',
|
||||
user_registered: 'success',
|
||||
user_created: 'success',
|
||||
email_verified_admin: 'success',
|
||||
invitation_accepted: 'success',
|
||||
invitation_sent: 'success',
|
||||
'2fa_enabled': 'success',
|
||||
|
||||
role_changed: 'warning',
|
||||
status_changed: 'warning',
|
||||
'2fa_disabled': 'warning',
|
||||
user_deleted: 'warning',
|
||||
account_self_deleted: 'warning',
|
||||
|
||||
login_failed: 'failure',
|
||||
};
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
const STATUS_META = {
|
||||
success: { label: 'Succès', color: '#16a34a' },
|
||||
warning: { label: 'Avertissement', color: '#f59e0b' },
|
||||
failure: { label: 'Échec', color: '#dc2626' },
|
||||
};
|
||||
|
||||
function fmtDateTime(str) {
|
||||
if (!str) return '—';
|
||||
const d = new Date(str.replace(' ', 'T') + (str.includes('+') ? '' : 'Z'));
|
||||
return d.toLocaleString('fr-FR', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
function StatusIcon({ status, color }) {
|
||||
const meta = STATUS_META[status];
|
||||
if (!meta) return null;
|
||||
const common = { width: 14, height: 14, viewBox: '0 0 24 24', fill: 'none', stroke: color || meta.color, strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' };
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', marginRight: 6, verticalAlign: 'middle' }} title={meta.label}>
|
||||
{status === 'success' && (
|
||||
<svg {...common}><circle cx="12" cy="12" r="10"/><polyline points="16 9 11 14 8 11"/></svg>
|
||||
)}
|
||||
{status === 'warning' && (
|
||||
<svg {...common}><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
||||
)}
|
||||
{status === 'failure' && (
|
||||
<svg {...common}><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryBadge({ cat, active, onClick }) {
|
||||
const meta = CATEGORY_META[cat] || { label: cat, color: '#6b7280' };
|
||||
function StatusBadge({ status, count, active, onClick }) {
|
||||
const meta = STATUS_META[status];
|
||||
return (
|
||||
<button
|
||||
onClick={() => onClick(cat)}
|
||||
onClick={() => onClick(status)}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '3px 10px',
|
||||
borderRadius: 20,
|
||||
border: `1px solid ${meta.color}`,
|
||||
@@ -57,11 +77,56 @@ function CategoryBadge({ cat, active, onClick }) {
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<StatusIcon status={status} color={active ? '#fff' : meta.color} />
|
||||
{meta.label}
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
minWidth: 18, height: 18, borderRadius: 9, padding: '0 5px',
|
||||
fontSize: 11, fontWeight: 700,
|
||||
background: active ? 'rgba(255,255,255,.25)' : `${meta.color}22`,
|
||||
color: active ? '#fff' : meta.color,
|
||||
}}>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const ACTION_LABELS = {
|
||||
login_success: 'Connexion réussie',
|
||||
login_failed: 'Échec de connexion',
|
||||
login_2fa_success: 'Connexion 2FA réussie',
|
||||
user_registered: 'Auto-inscription',
|
||||
user_created: 'Compte créé (admin)',
|
||||
user_deleted: 'Compte supprimé',
|
||||
account_self_deleted:'Compte supprimé (par l’utilisateur)',
|
||||
email_verified_admin:'Email vérifié (admin)',
|
||||
role_changed: 'Rôle modifié',
|
||||
status_changed: 'Statut modifié',
|
||||
'2fa_enabled': '2FA activé',
|
||||
'2fa_disabled': '2FA désactivé',
|
||||
invitation_sent: 'Invitation envoyée',
|
||||
invitation_accepted: 'Invitation acceptée',
|
||||
};
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function fmtPerson(name, email) {
|
||||
if (name && email) return `${name} (${email})`;
|
||||
if (email) return email;
|
||||
if (name) return name;
|
||||
return '—';
|
||||
}
|
||||
|
||||
function fmtDateTime(str) {
|
||||
if (!str) return '—';
|
||||
const d = new Date(str.replace(' ', 'T') + (str.includes('+') ? '' : 'Z'));
|
||||
return d.toLocaleString('fr-FR', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function DetailTooltip({ details }) {
|
||||
if (!details) return null;
|
||||
const entries = typeof details === 'object'
|
||||
@@ -119,9 +184,11 @@ export default function AuditLogsSection() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [categories, setCats] = useState([]);
|
||||
const [statusCounts, setStatusCounts] = useState({ success: 0, warning: 0, failure: 0 });
|
||||
|
||||
// Filtres
|
||||
const [filterCat, setFilterCat] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState('');
|
||||
const [filterSearch, setFilterSearch] = useState('');
|
||||
const [filterFrom, setFilterFrom] = useState('');
|
||||
const [filterTo, setFilterTo] = useState('');
|
||||
@@ -133,6 +200,7 @@ export default function AuditLogsSection() {
|
||||
try {
|
||||
const params = { page: p, limit: LIMIT };
|
||||
if (filterCat) params.category = filterCat;
|
||||
if (filterStatus) params.status = filterStatus;
|
||||
if (filterSearch) params.search = filterSearch;
|
||||
if (filterFrom) params.dateFrom = filterFrom;
|
||||
if (filterTo) params.dateTo = filterTo;
|
||||
@@ -143,6 +211,7 @@ export default function AuditLogsSection() {
|
||||
]);
|
||||
setRows(data.rows);
|
||||
setTotal(data.total);
|
||||
setStatusCounts(data.statusCounts || { success: 0, warning: 0, failure: 0 });
|
||||
setPage(p);
|
||||
if (!categories.length) setCats(cats);
|
||||
} catch (e) {
|
||||
@@ -150,9 +219,9 @@ export default function AuditLogsSection() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filterCat, filterSearch, filterFrom, filterTo]); // eslint-disable-line
|
||||
}, [filterCat, filterStatus, filterSearch, filterFrom, filterTo]); // eslint-disable-line
|
||||
|
||||
useEffect(() => { load(1); }, [filterCat, filterFrom, filterTo]); // eslint-disable-line
|
||||
useEffect(() => { load(1); }, [filterCat, filterStatus, filterFrom, filterTo]); // eslint-disable-line
|
||||
|
||||
// Recherche texte : debounce 350ms
|
||||
useEffect(() => {
|
||||
@@ -160,9 +229,10 @@ export default function AuditLogsSection() {
|
||||
return () => clearTimeout(t);
|
||||
}, [filterSearch]); // eslint-disable-line
|
||||
|
||||
const toggleCat = (cat) => setFilterCat(prev => prev === cat ? '' : cat);
|
||||
const toggleStatus = (st) => setFilterStatus(prev => prev === st ? '' : st);
|
||||
|
||||
const allCats = Object.keys(CATEGORY_META);
|
||||
const allStatuses = Object.keys(STATUS_META);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -173,10 +243,25 @@ export default function AuditLogsSection() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Filtres catégories */}
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 12 }}>
|
||||
{allCats.map(cat => (
|
||||
<CategoryBadge key={cat} cat={cat} active={filterCat === cat} onClick={toggleCat} />
|
||||
{/* Filtres : catégorie (liste déroulante) + statut (chips avec compteurs) */}
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', marginBottom: 16 }}>
|
||||
<select
|
||||
value={filterCat}
|
||||
onChange={e => setFilterCat(e.target.value)}
|
||||
style={{
|
||||
width: 'auto', minWidth: 180, flexShrink: 0,
|
||||
height: 32, padding: '0 8px', borderRadius: 8, border: '1px solid var(--border)',
|
||||
background: 'var(--surface)', color: 'var(--text)', fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<option value="">Toutes les catégories</option>
|
||||
{allCats.map(cat => (
|
||||
<option key={cat} value={cat}>{CATEGORY_META[cat]?.label || cat}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{allStatuses.map(st => (
|
||||
<StatusBadge key={st} status={st} count={statusCounts[st] ?? 0} active={filterStatus === st} onClick={toggleStatus} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -249,9 +334,9 @@ export default function AuditLogsSection() {
|
||||
{rows.map((row, i) => {
|
||||
const meta = CATEGORY_META[row.category] || { label: row.category, color: '#6b7280' };
|
||||
const label = ACTION_LABELS[row.action] || row.action;
|
||||
const actor = row.actor_name || row.actor_email || '—';
|
||||
const actor = fmtPerson(row.actor_name, row.actor_email);
|
||||
const target = row.target_email && row.target_id !== row.actor_id
|
||||
? (row.target_name || row.target_email)
|
||||
? fmtPerson(row.target_name, row.target_email)
|
||||
: '—';
|
||||
|
||||
return (
|
||||
@@ -275,6 +360,7 @@ export default function AuditLogsSection() {
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '7px 10px' }}>
|
||||
<StatusIcon status={ACTION_STATUS[row.action]} />
|
||||
<span style={{ color: 'var(--text)' }}>{label}</span>
|
||||
<DetailTooltip details={row.details} />
|
||||
</td>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -132,6 +132,8 @@ export default function ComptesSection() {
|
||||
});
|
||||
};
|
||||
|
||||
const multiDetenteur = investisseurs.length > 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card">
|
||||
@@ -154,7 +156,7 @@ export default function ComptesSection() {
|
||||
<tr>
|
||||
<th>Nom</th>
|
||||
<th style={{ width: '14%' }}>Type</th>
|
||||
<th style={{ width: '22%' }}>Détenteur</th>
|
||||
{multiDetenteur && <th style={{ width: '22%' }}>Détenteur</th>}
|
||||
<th style={{ width: '16%' }}>Banque</th>
|
||||
<th style={{ width: '10%' }}>Exonération</th>
|
||||
<th style={{ width: 80 }} />
|
||||
@@ -162,7 +164,7 @@ export default function ComptesSection() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{comptes.length === 0 && (
|
||||
<tr><td colSpan={6} className="text-muted" style={{ textAlign: 'center', fontStyle: 'italic' }}>
|
||||
<tr><td colSpan={multiDetenteur ? 6 : 5} className="text-muted" style={{ textAlign: 'center', fontStyle: 'italic' }}>
|
||||
Aucun compte défini.
|
||||
</td></tr>
|
||||
)}
|
||||
@@ -172,7 +174,7 @@ export default function ComptesSection() {
|
||||
<tr key={c.id}>
|
||||
<td style={{ fontWeight: 500 }}>{c.nom}</td>
|
||||
<td><span className="badge">{TYPE_COMPTE_LABELS[c.type] ?? c.type}</span></td>
|
||||
<td>{inv ? memberLabel(inv) : <span className="text-muted">—</span>}</td>
|
||||
{multiDetenteur && <td>{inv ? memberLabel(inv) : <span className="text-muted">—</span>}</td>}
|
||||
<td>{c.banque || <span className="text-muted">—</span>}</td>
|
||||
<td>
|
||||
{c.exoneration_fiscale && c.exoneration_fiscale !== 'aucune' ? (
|
||||
|
||||
@@ -1,10 +1,29 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api } from '../../api.js';
|
||||
|
||||
function IconBroom() {
|
||||
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M3 21l9-9"/><path d="M12.22 6.22L17 1.5l5.5 5.5-4.72 4.78"/><path d="M5 17c.5-2 2-3.5 4-4.5l3.5 3.5c-1 2-2.5 3.5-4.5 4"/></svg>;
|
||||
}
|
||||
|
||||
const PURGE_SCOPES = [
|
||||
{ value: 'all', label: 'Toutes les données (dépôts/retraits + investissements + remboursements)' },
|
||||
{ value: 'depots_retraits', label: 'Dépôts / Retraits uniquement' },
|
||||
{ value: 'investissements', label: "Investissements (et leurs remboursements liés)" },
|
||||
{ value: 'remboursements', label: 'Remboursements uniquement (les investissements sont conservés)' },
|
||||
];
|
||||
|
||||
function purgeSummary(counts, scope) {
|
||||
const parts = [];
|
||||
if (counts.depots_retraits > 0) parts.push(`${counts.depots_retraits} dépôt(s)/retrait(s)`);
|
||||
if (counts.investissements > 0) parts.push(`${counts.investissements} investissement(s)`);
|
||||
if (counts.remboursements > 0) parts.push(`${counts.remboursements} remboursement(s)`);
|
||||
if (scope === 'investissements' && counts.simul_remboursements > 0) {
|
||||
parts.push(`${counts.simul_remboursements} échéance(s) simulée(s)`);
|
||||
}
|
||||
if (parts.length === 0) return 'Aucune donnée à supprimer trouvée pour cette plateforme.';
|
||||
return `Supprimé : ${parts.join(', ')}.`;
|
||||
}
|
||||
|
||||
export default function DataCleanupSection() {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -12,11 +31,62 @@ export default function DataCleanupSection() {
|
||||
const [loadingReprocess, setLoadingReprocess] = useState(false);
|
||||
const [showDiffereModal, setShowDiffereModal] = useState(false);
|
||||
const [loadingDiffere, setLoadingDiffere] = useState(false);
|
||||
const [seuilMoisDiffere, setSeuilMoisDiffere] = useState(24);
|
||||
const [showEcheancierModal, setShowEcheancierModal] = useState(false);
|
||||
const [loadingEcheancier, setLoadingEcheancier] = useState(false);
|
||||
const [showBackfillModal, setShowBackfillModal] = useState(false);
|
||||
const [loadingBackfill, setLoadingBackfill] = useState(false);
|
||||
const [successMsg, setSuccessMsg] = useState(null);
|
||||
const [errorMsg, setErrorMsg] = useState(null);
|
||||
|
||||
const [plats, setPlats] = useState([]);
|
||||
const [purgePlatId, setPurgePlatId] = useState('');
|
||||
const [purgeScope, setPurgeScope] = useState('all');
|
||||
const [showPurgeModal, setShowPurgeModal] = useState(false);
|
||||
const [purgeConfirmText, setPurgeConfirmText] = useState('');
|
||||
const [purgePin, setPurgePin] = useState('');
|
||||
const [loadingPurge, setLoadingPurge] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/plateformes').then(setPlats).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const purgePlat = plats.find(p => String(p.id) === String(purgePlatId)) || null;
|
||||
// Plusieurs membres de la famille/entreprise peuvent avoir une plateforme du même nom
|
||||
// (ex. deux comptes "Enky" distincts) — on ajoute le détenteur au libellé pour lever
|
||||
// l'ambiguïté, uniquement quand plus d'un détenteur est présent dans la liste.
|
||||
const multiDetenteurPlats = new Set(plats.map(p => p.investisseur_id)).size > 1;
|
||||
|
||||
const openPurgeModal = () => {
|
||||
if (!purgePlat) return;
|
||||
setPurgeConfirmText('');
|
||||
setPurgePin(String(Math.floor(100000 + Math.random() * 900000)));
|
||||
setShowPurgeModal(true);
|
||||
};
|
||||
|
||||
const handlePurge = async () => {
|
||||
if (!purgePlat || purgeConfirmText.trim() !== purgePin) return;
|
||||
setLoadingPurge(true);
|
||||
setErrorMsg(null);
|
||||
setSuccessMsg(null);
|
||||
try {
|
||||
const r = await api.post(`/plateformes/${purgePlat.id}/purge-donnees`, {
|
||||
scope: purgeScope,
|
||||
// Le PIN est une confirmation côté interface uniquement ; l'API continue d'exiger
|
||||
// le nom exact de la plateforme, déjà connu ici puisque sélectionné dans le formulaire.
|
||||
confirmNom: purgePlat.nom,
|
||||
});
|
||||
setSuccessMsg(`"${r.plateforme}" — ${purgeSummary(r.counts, purgeScope)}`);
|
||||
setShowPurgeModal(false);
|
||||
setPurgeConfirmText('');
|
||||
} catch (err) {
|
||||
setErrorMsg(err.message || 'Une erreur est survenue.');
|
||||
setShowPurgeModal(false);
|
||||
} finally {
|
||||
setLoadingPurge(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReprocess = async () => {
|
||||
setLoadingReprocess(true);
|
||||
setErrorMsg(null);
|
||||
@@ -54,13 +124,18 @@ export default function DataCleanupSection() {
|
||||
setErrorMsg(null);
|
||||
setSuccessMsg(null);
|
||||
try {
|
||||
const { updated, detail } = await api.post('/investissements/fix-differe-dates', {});
|
||||
const { updated, detail, statutsMisAJour } = await api.post('/investissements/fix-differe-dates', {
|
||||
seuilMois: seuilMoisDiffere,
|
||||
});
|
||||
if (updated === 0) {
|
||||
setSuccessMsg('Aucune date incohérente détectée sur les prêts différés.');
|
||||
} else {
|
||||
const suffixeStatuts = statutsMisAJour > 0
|
||||
? ` (statut recalculé pour ${statutsMisAJour} prêt${statutsMisAJour > 1 ? 's' : ''} suite à la correction)`
|
||||
: '';
|
||||
setSuccessMsg(
|
||||
`${updated} prêt${updated > 1 ? 's' : ''} différé${updated > 1 ? 's' : ''} corrigé${updated > 1 ? 's' : ''} : ` +
|
||||
detail.map(d => d.nom_projet).join(', ') + '.'
|
||||
detail.map(d => d.nom_projet).join(', ') + '.' + suffixeStatuts
|
||||
);
|
||||
}
|
||||
setShowDiffereModal(false);
|
||||
@@ -72,6 +147,29 @@ export default function DataCleanupSection() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckEcheancierDiffere = async () => {
|
||||
setLoadingEcheancier(true);
|
||||
setErrorMsg(null);
|
||||
setSuccessMsg(null);
|
||||
try {
|
||||
const { updated, detail } = await api.post('/investissements/check-echeancier-differe', {});
|
||||
if (updated === 0) {
|
||||
setSuccessMsg("Échéancier cohérent pour tous les prêts différés — rien à corriger.");
|
||||
} else {
|
||||
setSuccessMsg(
|
||||
`Échéancier régénéré pour ${updated} prêt${updated > 1 ? 's' : ''} différé${updated > 1 ? 's' : ''} : ` +
|
||||
detail.map(d => d.nom_projet).join(', ') + '.'
|
||||
);
|
||||
}
|
||||
setShowEcheancierModal(false);
|
||||
} catch (err) {
|
||||
setErrorMsg(err.message || 'Une erreur est survenue.');
|
||||
setShowEcheancierModal(false);
|
||||
} finally {
|
||||
setLoadingEcheancier(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackfillComptes = async () => {
|
||||
setLoadingBackfill(true);
|
||||
setErrorMsg(null);
|
||||
@@ -135,8 +233,8 @@ export default function DataCleanupSection() {
|
||||
</div>
|
||||
<div className="text-muted" style={{ fontSize: 12 }}>
|
||||
Recalcule la date de 1ère échéance et la date cible à partir de la date de souscription
|
||||
et de la durée prévue. Seuls les prêts dont les dates s'écartent de plus de 2 ans
|
||||
de la valeur calculée sont corrigés.
|
||||
et de la durée prévue. Seuls les prêts dont les dates s'écartent de la valeur calculée
|
||||
de plus qu'un seuil choisi (3 à 24 mois) sont corrigés.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -148,6 +246,30 @@ export default function DataCleanupSection() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '14px 16px', borderRadius: 8,
|
||||
border: '1px solid var(--border)', background: 'var(--bg-secondary, var(--bg))',
|
||||
marginBottom: 10 }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, fontSize: 'var(--fs-sm)', marginBottom: 2 }}>
|
||||
Vérifier la cohérence de l'échéancier des prêts différés
|
||||
</div>
|
||||
<div className="text-muted" style={{ fontSize: 12 }}>
|
||||
Contrôle que l'échéancier de projection de chaque prêt différé comporte bien une unique
|
||||
échéance, à la date de 1ère échéance / date cible enregistrée sur le prêt. Régénère
|
||||
automatiquement l'échéancier en cas d'écart (ex. date corrigée sans mise à jour de la
|
||||
simulation).
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
style={{ marginLeft: 16, whiteSpace: 'nowrap', flexShrink: 0 }}
|
||||
onClick={() => setShowEcheancierModal(true)}
|
||||
disabled={loadingEcheancier}
|
||||
>
|
||||
Vérifier
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '14px 16px', borderRadius: 8,
|
||||
border: '1px solid var(--border)', background: 'var(--bg-secondary, var(--bg))',
|
||||
@@ -171,6 +293,45 @@ export default function DataCleanupSection() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '14px 16px', borderRadius: 8,
|
||||
border: '1px solid var(--border)', background: 'var(--bg-secondary, var(--bg))',
|
||||
marginBottom: 10 }}>
|
||||
<div style={{ fontWeight: 500, fontSize: 'var(--fs-sm)', marginBottom: 2 }}>
|
||||
Supprimer les données d'une plateforme
|
||||
</div>
|
||||
<div className="text-muted" style={{ fontSize: 12, marginBottom: 12 }}>
|
||||
Efface les données rattachées à une plateforme (utile pour repartir d'une base propre avant
|
||||
une réimportation). La fiche plateforme elle-même n'est jamais supprimée par cette action.
|
||||
</div>
|
||||
<div className="row" style={{ gap: 10, alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<div style={{ flex: '1 1 220px', minWidth: 200 }}>
|
||||
<label style={{ fontSize: 12 }}>Plateforme</label>
|
||||
<select value={purgePlatId} onChange={e => setPurgePlatId(e.target.value)}>
|
||||
<option value="">Sélectionnez une plateforme…</option>
|
||||
{plats.map(p => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.nom}{multiDetenteurPlats && p.investisseur_nom ? ` — ${p.investisseur_nom}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ flex: '1 1 280px', minWidth: 240 }}>
|
||||
<label style={{ fontSize: 12 }}>Données à supprimer</label>
|
||||
<select value={purgeScope} onChange={e => setPurgeScope(e.target.value)}>
|
||||
{PURGE_SCOPES.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
className="danger"
|
||||
style={{ flexShrink: 0 }}
|
||||
onClick={openPurgeModal}
|
||||
disabled={!purgePlatId || loadingPurge}
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '14px 16px', borderRadius: 8,
|
||||
border: '1px solid var(--border)', background: 'var(--bg-secondary, var(--bg))' }}>
|
||||
@@ -207,11 +368,28 @@ export default function DataCleanupSection() {
|
||||
border: '1px solid var(--border)' }}>
|
||||
date souscription + durée (mois)
|
||||
</p>
|
||||
<p style={{ margin: '0 0 20px', lineHeight: 1.6 }} className="text-muted">
|
||||
<p style={{ margin: '0 0 12px', lineHeight: 1.6 }} className="text-muted">
|
||||
La correction ne s'applique que si l'écart entre la date existante et la date calculée
|
||||
dépasse <strong>2 ans</strong>. Les simulations de remboursement associées ne sont pas
|
||||
recalculées automatiquement.
|
||||
dépasse le seuil choisi ci-dessous. L'échéancier de projection (simulation de
|
||||
remboursement) est régénéré automatiquement avec la date corrigée.
|
||||
</p>
|
||||
<div style={{ margin: '0 0 20px' }}>
|
||||
<label style={{ display: 'block', fontSize: 'var(--fs-sm)', fontWeight: 500, marginBottom: 6 }}>
|
||||
Seuil d'écart
|
||||
</label>
|
||||
<select
|
||||
value={seuilMoisDiffere}
|
||||
onChange={e => setSeuilMoisDiffere(Number(e.target.value))}
|
||||
disabled={loadingDiffere}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<option value={3}>3 mois</option>
|
||||
<option value={6}>6 mois</option>
|
||||
<option value={12}>12 mois</option>
|
||||
<option value={18}>18 mois</option>
|
||||
<option value={24}>24 mois (défaut)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button onClick={() => setShowDiffereModal(false)} disabled={loadingDiffere}>Annuler</button>
|
||||
<button className="primary" onClick={() => handleFixDiffereDates()} disabled={loadingDiffere}>
|
||||
@@ -222,6 +400,31 @@ export default function DataCleanupSection() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showEcheancierModal && (
|
||||
<div className="modal-overlay" onClick={() => setShowEcheancierModal(false)}>
|
||||
<div className="modal" style={{ maxWidth: 480 }} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header" style={{ borderBottom: '1px solid var(--border)', paddingBottom: 12, marginBottom: 16 }}>
|
||||
<h3 style={{ margin: 0 }}>Vérifier la cohérence de l'échéancier des prêts différés</h3>
|
||||
</div>
|
||||
<p style={{ margin: '0 0 12px', lineHeight: 1.6 }}>
|
||||
Pour chaque prêt de type <strong>différé</strong>, cette opération vérifie que l'échéancier
|
||||
de projection (simulation de remboursement) contient bien une unique échéance, à la date
|
||||
enregistrée sur le prêt (1ère échéance / date cible).
|
||||
</p>
|
||||
<p style={{ margin: '0 0 20px', lineHeight: 1.6 }} className="text-muted">
|
||||
En cas d'écart (échéance manquante, en double, ou à une date différente de celle du prêt),
|
||||
l'échéancier est régénéré automatiquement à partir des données actuelles du prêt.
|
||||
</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button onClick={() => setShowEcheancierModal(false)} disabled={loadingEcheancier}>Annuler</button>
|
||||
<button className="primary" onClick={handleCheckEcheancierDiffere} disabled={loadingEcheancier}>
|
||||
{loadingEcheancier ? 'Vérification en cours…' : 'Lancer la vérification'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showBackfillModal && (
|
||||
<div className="modal-overlay" onClick={() => setShowBackfillModal(false)}>
|
||||
<div className="modal" style={{ maxWidth: 480 }} onClick={e => e.stopPropagation()}>
|
||||
@@ -276,7 +479,65 @@ export default function DataCleanupSection() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{showPurgeModal && purgePlat && (
|
||||
<div className="modal-overlay" onClick={() => setShowPurgeModal(false)}>
|
||||
<div className="modal" style={{ maxWidth: 480 }} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header" style={{ borderBottom: '1px solid var(--border)', paddingBottom: 12, marginBottom: 16 }}>
|
||||
<h3 style={{ margin: 0, color: 'var(--danger, #ef4444)' }}>⚠ Suppression de données — action irréversible</h3>
|
||||
</div>
|
||||
<p style={{ margin: '0 0 12px', lineHeight: 1.6 }}>
|
||||
Vous vous apprêtez à supprimer, pour la plateforme <strong>{purgePlat.nom}</strong>
|
||||
{multiDetenteurPlats && purgePlat.investisseur_nom ? <> (<strong>{purgePlat.investisseur_nom}</strong>)</> : null} :
|
||||
</p>
|
||||
<p style={{ margin: '0 0 12px', lineHeight: 1.6, fontFamily: 'monospace', fontSize: 13,
|
||||
background: 'var(--surface-2, var(--bg))', padding: '8px 12px', borderRadius: 6,
|
||||
border: '1px solid var(--border)' }}>
|
||||
{PURGE_SCOPES.find(s => s.value === purgeScope)?.label}
|
||||
</p>
|
||||
<p style={{ margin: '0 0 16px', lineHeight: 1.6 }} className="text-muted">
|
||||
La fiche plateforme "{purgePlat.nom}" est conservée. Cette opération ne peut pas être annulée.
|
||||
</p>
|
||||
<div style={{ margin: '0 0 20px' }}>
|
||||
<label style={{ display: 'block', fontSize: 'var(--fs-sm)', fontWeight: 500, marginBottom: 8 }}>
|
||||
Pour confirmer, retapez le code affiché ci-dessous :
|
||||
</label>
|
||||
<div style={{
|
||||
textAlign: 'center', margin: '0 0 14px', padding: '14px 10px',
|
||||
background: 'var(--surface-2, var(--bg))', border: '1px dashed var(--danger, #ef4444)',
|
||||
borderRadius: 8,
|
||||
}}>
|
||||
<span style={{
|
||||
fontFamily: 'monospace', fontSize: 30, fontWeight: 700,
|
||||
letterSpacing: 8, color: 'var(--danger, #ef4444)',
|
||||
}}>
|
||||
{purgePin}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
value={purgeConfirmText}
|
||||
onChange={e => setPurgeConfirmText(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="000000"
|
||||
disabled={loadingPurge}
|
||||
style={{ width: '100%', textAlign: 'center', fontFamily: 'monospace', fontSize: 18, letterSpacing: 4 }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button onClick={() => setShowPurgeModal(false)} disabled={loadingPurge}>Annuler</button>
|
||||
<button
|
||||
className="danger"
|
||||
onClick={handlePurge}
|
||||
disabled={loadingPurge || purgeConfirmText.trim() !== purgePin}
|
||||
>
|
||||
{loadingPurge ? 'Suppression en cours…' : 'Confirmer la suppression'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showModal && (
|
||||
<div className="modal-overlay" onClick={() => setShowModal(false)}>
|
||||
<div className="modal" style={{ maxWidth: 440 }} onClick={e => e.stopPropagation()}>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,12 @@ function dlBlob(content, filename, type) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/** Date du jour au format YYYY-MM-DD, en heure locale (ne pas utiliser toISOString ici, décale d'un jour selon le fuseau). */
|
||||
function localToday() {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const countryLabel = code => COUNTRIES.find(c => c.code === code)?.name ?? code ?? '—';
|
||||
const FISCALITE_LABELS = {
|
||||
flat_tax: 'Flat Tax',
|
||||
@@ -243,7 +249,20 @@ function PlatDetailPanel({ plat, onEdit }) {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="dr-detail-title">Détail de la plateforme</div>
|
||||
<div className="dr-detail-title" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span>Détail de la plateforme</span>
|
||||
{plat.referentiel_id && (
|
||||
<button
|
||||
type="button"
|
||||
className="chip-cat"
|
||||
onClick={() => navigate(`/referentiel/${plat.referentiel_id}`)}
|
||||
title="Voir le profil de la plateforme"
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
Profil
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="dr-detail-fields">
|
||||
{fields.map(f => (
|
||||
<div className="dr-detail-field" key={f.label}>
|
||||
@@ -253,15 +272,6 @@ function PlatDetailPanel({ plat, onEdit }) {
|
||||
))}
|
||||
</div>
|
||||
<div className="dr-detail-footer">
|
||||
{plat.referentiel_id && (
|
||||
<button
|
||||
className="ghost"
|
||||
onClick={() => navigate(`/referentiel/${plat.referentiel_id}`)}
|
||||
style={{ marginBottom: 8, width: '100%', fontSize: 13 }}
|
||||
>
|
||||
Voir le profil de la plateforme
|
||||
</button>
|
||||
)}
|
||||
<button className="dr-detail-edit-btn" onClick={() => onEdit(plat)}>
|
||||
Modifier la plateforme
|
||||
</button>
|
||||
@@ -279,6 +289,13 @@ function PlatDetailPanel({ plat, onEdit }) {
|
||||
function PlatPickerModal({ open, onClose, referentiel, onSelect, onManual, err }) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [domFilter, setDomFilter] = useState('all'); // 'all' | 'fr' | 'etr'
|
||||
const [confirming, setConfirming] = useState(null); // ref en cours de confirmation
|
||||
const [dateOuverture, setDateOuverture] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (open) { setConfirming(null); setDateOuverture(''); }
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const q = search.trim().toLowerCase();
|
||||
@@ -294,6 +311,53 @@ function PlatPickerModal({ open, onClose, referentiel, onSelect, onManual, err }
|
||||
return f ? (LOGO_BASE + f) : null;
|
||||
};
|
||||
|
||||
/* ── Étape de confirmation : date d'ouverture du compte ── */
|
||||
if (confirming) {
|
||||
const img = imgFor(confirming);
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title="Ajouter une plateforme"
|
||||
onClose={onClose}
|
||||
width={480}
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
|
||||
<button type="button" className="ghost" onClick={() => setConfirming(null)}>← Retour</button>
|
||||
<button type="button" className="primary" onClick={() => onSelect(confirming, dateOuverture)}>
|
||||
Ajouter
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
{img
|
||||
? <img src={img} alt={confirming.nom} style={{ width: 44, height: 44, objectFit: 'contain', borderRadius: 8, flexShrink: 0 }} />
|
||||
: <div style={{ width: 44, height: 44, borderRadius: 8, flexShrink: 0, background: 'var(--surface-2)', border: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 18, color: 'var(--text-muted)' }}>🏦</div>
|
||||
}
|
||||
<div style={{ fontWeight: 600, fontSize: 'var(--fs-md)' }}>{confirming.nom}</div>
|
||||
</div>
|
||||
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: 'var(--fs-sm)', lineHeight: 1.5 }}>
|
||||
Pouvez-vous SVP préciser la date d'ouverture de votre compte sur cette plateforme ?
|
||||
</p>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontWeight: 600, marginBottom: 4, fontSize: 'var(--fs-sm)' }}>
|
||||
Date d'ouverture du compte
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
autoFocus
|
||||
value={dateOuverture}
|
||||
onChange={e => setDateOuverture(e.target.value)}
|
||||
style={{ width: '100%', boxSizing: 'border-box' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
@@ -368,7 +432,7 @@ function PlatPickerModal({ open, onClose, referentiel, onSelect, onManual, err }
|
||||
<button
|
||||
key={r.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(r)}
|
||||
onClick={() => { setConfirming(r); setDateOuverture(localToday()); }}
|
||||
style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
gap: 8, padding: '16px 10px 12px',
|
||||
@@ -572,9 +636,9 @@ function PlatForm({ state, setter, logoFile, setLogoFile, logoPreview, setLogoPr
|
||||
const inherited = (state.inherited_cat_ids || []).includes(c.id);
|
||||
const checked = (state.categories_inv_ids || []).includes(c.id);
|
||||
return (
|
||||
<label key={c.id} style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: inherited ? 'default' : 'pointer',
|
||||
opacity: inherited ? 0.6 : 1 }}>
|
||||
<input type="checkbox" checked={checked} disabled={inherited} style={{ width: 'auto' }}
|
||||
<label key={c.id} style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}
|
||||
title={inherited ? 'Catégorie héritée du référentiel — décochez pour la retirer de cette plateforme' : undefined}>
|
||||
<input type="checkbox" checked={checked} style={{ width: 'auto' }}
|
||||
onChange={e => setter(s => ({
|
||||
...s,
|
||||
categories_inv_ids: e.target.checked
|
||||
@@ -595,9 +659,9 @@ function PlatForm({ state, setter, logoFile, setLogoFile, logoPreview, setLogoPr
|
||||
const inherited = (state.inherited_sect_ids || []).includes(s.id);
|
||||
const checked = (state.secteurs_inv_ids || []).includes(s.id);
|
||||
return (
|
||||
<label key={s.id} style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: inherited ? 'default' : 'pointer',
|
||||
opacity: inherited ? 0.6 : 1 }}>
|
||||
<input type="checkbox" checked={checked} disabled={inherited} style={{ width: 'auto' }}
|
||||
<label key={s.id} style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}
|
||||
title={inherited ? 'Secteur hérité du référentiel — décochez pour le retirer de cette plateforme' : undefined}>
|
||||
<input type="checkbox" checked={checked} style={{ width: 'auto' }}
|
||||
onChange={e => setter(prev => ({
|
||||
...prev,
|
||||
secteurs_inv_ids: e.target.checked
|
||||
@@ -775,16 +839,20 @@ export default function PlateformesSection() {
|
||||
};
|
||||
|
||||
/* ── Ajouter depuis le référentiel ──────────────────────────── */
|
||||
const addPlatFromRef = async (ref) => {
|
||||
const addPlatFromRef = async (ref, dateOuverture) => {
|
||||
try {
|
||||
await api.post('/plateformes', {
|
||||
nom: ref.nom,
|
||||
url: ref.url || '',
|
||||
domiciliation: ref.domiciliation || 'FR',
|
||||
fiscalite: ref.fiscalite || 'flat_tax',
|
||||
taux_fiscalite_locale: ref.taux_fiscalite_locale ?? null,
|
||||
type_produit_fiscal: ref.type_produit_fiscal || '2TT',
|
||||
methode_remboursement: 'portefeuille',
|
||||
methode_remboursement: ref.methode_remboursement || 'portefeuille',
|
||||
type_pret_defaut: ref.type_pret_defaut || null,
|
||||
freq_interets_defaut: ref.freq_interets_defaut || null,
|
||||
referentiel_id: ref.id,
|
||||
date_ouverture: dateOuverture || null,
|
||||
});
|
||||
setShowAddPicker(false);
|
||||
await load();
|
||||
@@ -935,6 +1003,8 @@ export default function PlateformesSection() {
|
||||
} catch (e) { setErr(e.message); }
|
||||
};
|
||||
|
||||
const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1;
|
||||
|
||||
/* ── PFU CRUD ────────────────────────────────────────────────── */
|
||||
|
||||
|
||||
@@ -993,14 +1063,14 @@ export default function PlateformesSection() {
|
||||
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Domiciliation</th>
|
||||
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Catégories</th>
|
||||
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Secteurs</th>
|
||||
<th style={{ padding: '10px 8px', textAlign: 'left' }}>Détenteur</th>
|
||||
{multiDetenteur && <th style={{ padding: '10px 8px', textAlign: 'left' }}>Détenteur</th>}
|
||||
<th style={{ padding: '10px 16px', textAlign: 'center', width: 60 }}>Invest.</th>
|
||||
<th style={{ padding: '10px 8px', width: 40 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{plats.length === 0 && (
|
||||
<tr><td colSpan={7} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>Aucune plateforme</td></tr>
|
||||
<tr><td colSpan={multiDetenteur ? 8 : 7} className="text-muted" style={{ textAlign: 'center', padding: 24 }}>Aucune plateforme</td></tr>
|
||||
)}
|
||||
{plats.map((p, i) => {
|
||||
const imgSrc = p.icone_filename ? logoUrl(p.icone_filename) : p.logo_filename ? logoUrl(p.logo_filename) : null;
|
||||
@@ -1047,9 +1117,11 @@ export default function PlateformesSection() {
|
||||
{sects.length === 0 && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>—</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '10px 8px', fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{inv ? memberLabel(inv) : '—'}
|
||||
</td>
|
||||
{multiDetenteur && (
|
||||
<td style={{ padding: '10px 8px', fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{inv ? memberLabel(inv) : '—'}
|
||||
</td>
|
||||
)}
|
||||
<td style={{ padding: '10px 16px', textAlign: 'center', fontWeight: 600,
|
||||
color: (p.nb_investissements ?? 0) > 0 ? 'var(--text)' : 'var(--text-muted)' }}>
|
||||
{p.nb_investissements ?? 0}
|
||||
|
||||
+22
-1
@@ -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; }
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
*.log
|
||||
README.md
|
||||
@@ -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"]
|
||||
@@ -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).
|
||||
Generated
+1684
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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); }
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user