diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..390888b --- /dev/null +++ b/.env.example @@ -0,0 +1,25 @@ +# ============================================================================= +# Crowdlending App — Variables d'environnement +# Copier ce fichier en .env et adapter les valeurs +# NE JAMAIS commiter .env dans git +# ============================================================================= + +# --- Développement local --- +# NODE_ENV=development +# PORT=4000 +# JWT_SECRET=dev-secret-local +# JWT_EXPIRES_IN=7d +# DB_PATH=./data/crowdlending.db +# UPLOAD_DIR=./uploads + +# --- Production (Docker) --- +NODE_ENV=production +PORT=4000 + +# Générer avec : openssl rand -hex 32 +JWT_SECRET=change-me-in-production +JWT_EXPIRES_IN=7d + +# Chemins dans le container (ne pas modifier) +DB_PATH=/app/data/crowdlending.db +UPLOAD_DIR=/app/uploads diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fd6e3f0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# Dependencies +node_modules/ +*/node_modules/ + +# Production builds +dist/ +build/ +*/dist/ +*/build/ + +# Environment +.env +.env.local +.env.*.local +*/.env + +# Logs +logs +*.log +npm-debug.log* + +# SQLite database +data/ +*.sqlite +*.sqlite3 +*.db + +# Editor +.vscode/ +.idea/ +*.swp +*.swo +.DS_Store + +# Uploads (Excel imports) +backend/uploads/ + +# Coverage +coverage/ +.nyc_output/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0fe5bfa --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,378 @@ +# CLAUDE.md — Crowdlending App + +Tracker de portefeuille de crowdlending (prêts participatifs). Application full-stack monorepo gérée localement, single-user multi-investisseur. + +--- + +## Système de mémorisation + +Au début de chaque session, lis le fichier MEMORY.md avant de répondre. Utilises les informations qu'il contient pour orienter ton travail. Ne divulgues pas tes découvertes, contentes-toi d'en prendre connaissance. + +Lorsque je vous dis « sauvegarde dans ta mémoire », notes immédiatement l'information dans MEMORY.md et confirmes que tu l'as fait. + +**Où enregistrer les informations :** Appliquez deux critères pour déterminer où enregistrer une information. Critère 1 : L'information prescrit-elle un comportement ? Recherchez des expressions comme « toujours », « jamais », « avant de faire X, faites Y ». Si oui, ajoutez-la à ce fichier (CLAUDE.md) dans la section appropriée. Critère 2 : L'information décrit-elle un fait susceptible d'évoluer ? Coordonnées, état d'avancement d'un projet, décisions, informations que je t'ai demandé de retenir. Si oui, ajoutes-la à MEMORY.md. En cas de doute, suggères le fichier dans lequel elle devrait être enregistrée et demandes-moi confirmation. + +## Préférences + +- Adoptes un ton professionnel et conversationnel. Si le texte ressemble à une note de service, Reformules-le. +- Sois concis dans tes réponses (moins de 300 mots), sauf si je te demandes plus de détails. +- Utilisez des puces pour les listes, mais rédiges tes explications sous forme de paragraphes. +- Donnes-moi une recommandation pertinente. Ne me proposes pas trois options, sauf si je vous demande explicitement des alternatives. + +## Règles + +- Poses toujours des questions pour clarifier la situation avant d'entreprendre une tâche complexe. +- Si tu as un doute, exprimez-le. Ne fais pas de suppositions. + +## Stack technique + +| Couche | Tech | +|---|---| +| Frontend | React 18, Vite, React Router v6 | +| Backend | Node.js / Express, better-sqlite3 | +| Validation | Zod (backend) | +| Auth | JWT (header `Authorization: Bearer`) | +| Styles | CSS variables custom (pas de framework UI) | +| Build | Vite (frontend) / Node ESM (backend) | + +**Pas de TypeScript.** Pas de Redux. Pas d'ORM. + +--- + +## Structure du projet + +``` +crowdlending-app/ +├── frontend/ +│ └── src/ +│ ├── App.jsx # Routeur principal +│ ├── main.jsx # Providers imbriqués +│ ├── api.js # Client HTTP centralisé +│ ├── styles.css # CSS global + variables thème +│ ├── context/ +│ │ ├── AuthContext.jsx # JWT, isAdmin +│ │ ├── InvestisseurContext.jsx # activeId, activeView, investisseurs +│ │ ├── ThemeContext.jsx # dark/light/system +│ │ └── UiContext.jsx # sidebar, fontScale, langue, devise, displayMode +│ ├── components/ +│ │ ├── Layout.jsx # Shell app (sidebar + topbar) +│ │ ├── UserMenu.jsx # Menu popup utilisateur +│ │ ├── Modal.jsx # Modale générique +│ │ ├── CategorySelect.jsx # Multi-select catégories +│ │ ├── InteretsChart.jsx # Courbe cumul intérêts (SVG) +│ │ ├── InteretsDistributionChart.jsx # Treemap plateformes (SVG) +│ │ ├── SoldeChart.jsx # Courbe solde dépôts (SVG) +│ │ ├── DistributionChart.jsx # Distribution investissements +│ │ └── Logo.jsx +│ ├── pages/ +│ │ ├── Dashboard.jsx +│ │ ├── Investissements.jsx +│ │ ├── InvestissementDetail.jsx +│ │ ├── Remboursements.jsx +│ │ ├── DepotsRetraits.jsx +│ │ ├── Fiscal2778.jsx # Récapitulatif fiscal (anciennement "Flat Tax") +│ │ ├── Settings.jsx # Hub paramètres (apparence, plateformes, imports…) +│ │ ├── MonCompte.jsx # Profil, sécurité, nettoyage données +│ │ ├── Admin.jsx # Administration plateforme (admin only) +│ │ ├── Login.jsx / Register.jsx +│ │ └── SimulRemboursements.jsx +│ └── utils/ +│ └── format.js # fmtEUR, fmtDate, fmtPct, fmtStatut, memberLabel… +└── backend/ + └── src/ + ├── server.js # Express app + routes montées + ├── db/ + │ ├── index.js # Init SQLite + toutes les migrations ADD COLUMN + │ └── schema.sql # Schéma de référence (CREATE TABLE IF NOT EXISTS) + ├── routes/ + │ ├── auth.js # /api/auth + │ ├── investisseurs.js # /api/investisseurs + │ ├── plateformes.js # /api/plateformes + │ ├── categories.js # /api/categories + │ ├── depotsRetraits.js + │ ├── investissements.js + │ ├── remboursements.js + │ ├── simul.js # /api/simul (projections) + │ ├── dashboard.js + │ ├── fiscal2778.js + │ ├── imports.js + │ ├── pfu.js # Taux PFU par année + │ ├── notation.js + │ ├── garanties.js + │ └── admin.js # Requirest requireAdmin middleware + ├── middleware/ + │ ├── auth.js # requireAuth, requireAdmin + │ └── errorHandler.js # HttpError + Zod errors + └── jobs/ + └── autoStatut.js # Job auto-statut investissements +``` + +--- + +## Base de données (SQLite) + +Tables principales : + +| Table | Rôle | +|---|---| +| `users` | Comptes utilisateurs, champ `role` ('user'/'admin') | +| `investisseurs` | Profils investisseurs par user (famille/entreprise), `is_principal` | +| `plateformes` | Référentiel plateformes, `domiciliation`, `fiscalite`, `taux_fiscalite_locale` | +| `categories_plateforme` | Catégories, junction `plateforme_categories` | +| `investissements` | Un prêt par ligne, `investisseur_id`, `statut`, `type_remb`, `freq_interets`, `date_debut_simul` | +| `remboursements` | Flux réels : `capital`, `cashback`, `interets_bruts`, `prelev_sociaux`, `prelev_forfaitaire`, `interets_nets`, `net_recu` | +| `simul_remboursements` | Projections générées : `capital_prevu`, `interets_prevus`, `total_prevu` | +| `depots_retraits` | Mouvements de cash (depot/retrait) par plateforme | +| `taux_pfu` | Taux PFU par année (`prelev_sociaux` + `impot_revenu`) | +| `imports` | Logs d'imports CSV/XLS | +| `notation` | Critères de notation par plateforme | +| `garanties` | Référentiel garanties | + +Vues SQL : `v_solde_plateforme`, `v_synthese_inv`, `v_interets_annuels` + +**Pattern migration** : toutes les évolutions de schéma sont des `ALTER TABLE ADD COLUMN` dans `backend/src/db/index.js`, protégées par un check `PRAGMA table_info()`. Ne jamais modifier `schema.sql` pour les nouvelles colonnes — ajouter uniquement une migration dans `index.js`. + +--- + +## Contextes React + +### `UiContext` — état UI persisté en localStorage + +```js +const { + sidebarCollapsed, toggleSidebar, // cl_ui_sidebar_collapsed + fontScale, setFontScale, // cl_fontscale : 'large'|'medium'|'compact' + langue, setLangue, // cl_langue : 'fr'|'en' + devise, setDevise, // cl_devise : 'EUR'|'USD'|'GBP'|'CHF'|'CAD'|'SGD' + displayMode, setDisplayMode, // cl_display_mode : 'net'|'brut' — LE TOGGLE GLOBAL +} = useUi(); +``` + +**`displayMode`** est le toggle Brut/Net global affiché dans la topbar. Il pilote l'affichage des intérêts et rendements sur **toutes les pages**. Défaut : `'net'`. + +### `InvestisseurContext` + +```js +const { activeId, activeView, investisseurs, activeViewMember, activeViewMember } = useInvestisseur(); +// activeView : 'single' | 'all' +// activeId : id de l'investisseur sélectionné (null si vue 'all') +``` + +Les appels API utilisent `{ scope: 'all' }` quand `activeView === 'all'`. + +### `AuthContext` + +```js +const { token, user, isAdmin, loading, logout } = useAuth(); +``` + +### `ThemeContext` + +```js +const { theme, setTheme } = useTheme(); // 'light'|'dark'|'system' +``` + +--- + +## Layout & Navigation + +### Structure de la sidebar + +La sidebar contient **uniquement les 5 pages de données** : +- Tableau de bord (`/`) +- Investissements (`/investissements`) +- Dépôts / Retraits (`/depots-retraits`) +- Remboursements (`/remboursements`) +- Fiscalité (`/2778-sd`) + +Les pages **Settings, MonCompte, Admin** sont accessibles via le **popup UserMenu** (coin bas gauche sidebar) : +- Mon compte → `/compte` +- Administration → `/admin` (visible uniquement si `isAdmin`) +- Paramètres → `/settings` + +### Topbar globale (`Layout.jsx`) + +Boutons : "+ Ajout Investissement" | "+ Nouveau remboursement" | "+ Nouveau dépôt/retrait" | **Toggle Brut/Net** + +Le toggle Brut/Net est un `div.display-toggle` avec deux `button.display-toggle-btn`. L'état est géré via `UiContext.displayMode`. + +--- + +## Pattern pages "compte" (Settings, MonCompte, Admin) + +Ces pages utilisent le layout `account-layout` / `account-sidebar` / `account-content` (classes CSS). Navigation interne par **URL param `?section=`** (pas de state local). + +```jsx +const section = new URLSearchParams(search).get('section') || 'default'; +const setSection = (s) => navigate(`/settings?section=${s}`, { replace: true }); +``` + +**Settings** — sections disponibles : +- `apparence` (défaut) — thème, police, langue, devise +- `plateformes` — CRUD plateformes + catégories +- `categories` — CRUD catégories de plateformes +- `garanties` — référentiel garanties +- `pfu` — taux PFU par année +- `notation` — critères de notation par plateforme +- `imports` — import CSV/XLS (anciennement page `/imports`) + +**MonCompte** — sections : `profil`, `securite`, `nettoyage` + +**Admin** — sections : `users`, `create`, `job-logs` + +Routes dépréciées redirigées : `/preferences → /settings?section=apparence`, `/imports → /settings?section=imports` + +--- + +## Toggle Brut/Net — règles d'application par page + +Ce toggle global (`displayMode === 'net'` → `netMode`) influence les données affichées. Voici comment chaque page l'implémente : + +### Dashboard (`Dashboard.jsx`) +- KPI **"Intérêts perçus — Brut/Net"** : Net = `bruts - prelev_sociaux - prelev_forfaitaire` +- Table annuelle : dernière colonne bascule entre "Net reçu" (brut) et "Intérêts nets" calculé (net) +- Table Échéances du mois : colonne Intérêts → brut ou estimation nette via taux PFU de l'année +- Charge `pfuRates` au montage (`api.get('/pfu')`) + +### Investissements (`Investissements.jsx`) +- KPI **"Intérêts perçus — Brut/Net"** : utilise `interets_percus` (brut, agrégé backend) ou `net_recu_total` (net, agrégé backend via `SUM(r.net_recu)`) +- Colonne tableau **"Int. perçus (Brut/Net)"** : idem +- Backend retourne les deux : `interets_percus` et `net_recu_total` dans la requête SQL + +### InvestissementDetail (`InvestissementDetail.jsx`) +- KPI **"Intérêts perçus — Brut/Net"** : brut = `SUM(r.interets_bruts)`, net = `SUM(r.interets_nets)` (depuis remboursements chargés) +- KPI **"Rendement annualisé — Brut/Net"** (XIRR) : bascule entre `rendementReelBrut` et `rendementReel` +- KPI "Intérêts prévus" **supprimé** (info dans le tableau projections) +- Projections : colonne **"Intérêts (Brut / Net estimé)"** — estimation nette via `getRatesForYear(date_prevue)` + +### Remboursements (`Remboursements.jsx`) +- `netMode` dérivé de `displayMode` (plus de state local `netMode`) +- KPI : 3 KPIs (Capital remboursé, Cashback, **Intérêts — Brut/Net**) +- Graphique courbe et treemap reçoivent `netMode` en prop (le toggle interne du graphique a été supprimé) +- Table Plateformes : **une seule colonne Intérêts** (Brut/Net) — les colonnes séparées bruts/nets supprimées +- Projections : colonne Intérêts bascule, avec estimation nette via taux PFU + +### Fiscal2778 (`Fiscal2778.jsx`) +- Titre : "Fiscalité — Récapitulatif fiscal" (anciennement "Flat Tax (hors France)") + +--- + +## Modèle financier — calculs clés + +``` +interets_nets = interets_bruts - prelev_sociaux - prelev_forfaitaire +net_recu = capital + cashback + interets_nets +taux PFU France = prelev_sociaux (17.2%) + impot_revenu (12.8%) = 30% +``` + +**Estimation nette pour projections** (plateformes non-françaises ou simul) : +```js +const rates = pfuRates.find(r => r.annee === year); +const reduction = rates ? (rates.prelev_sociaux + rates.impot_revenu) / 100 : 0; +const interets_net_estime = interets_bruts * (1 - reduction); +``` + +**XIRR** — calculé uniquement sur les investissements `statut === 'rembourse'`. Flux bruts = capital + cashback + interets_bruts. Flux nets = net_recu. + +--- + +## Modèle Plateforme (évolution récente) + +Trois nouveaux champs ajoutés : + +| Champ | Valeurs | +|---|---| +| `domiciliation` | `'france'` \| `'zone_europeenne'` \| `'hors_zone_europeenne'` | +| `fiscalite` | `'flat_tax'` \| `'sans_fiscalite_locale'` \| `'avec_fiscalite_locale'` | +| `taux_fiscalite_locale` | REAL nullable | + +**Règle métier** (appliquée backend ET frontend) : si `domiciliation === 'france'`, alors `fiscalite` est forcée à `'flat_tax'` et `taux_fiscalite_locale` est null. Les options "Sans fiscalité locale" / "Avec fiscalité locale" ne s'affichent que pour les plateformes non-françaises. + +Helpers frontend dans `Settings.jsx` : `DOMICILIATION_LABELS`, `FISCALITE_LABELS`, `fmtFiscalite(p)`, `applyDomiciliationChange(state, newDomicil)`, `applyFiscaliteChange(state, newFiscalite)`. + +--- + +## Conventions de code + +### Appels API +```js +// api.js — client centralisé, gère JWT automatiquement +api.get('/investissements', { scope: 'all' }) +api.post('/remboursements', payload) +api.put(`/investissements/${id}`, payload) +api.del(`/plateformes/${id}`) +``` + +### Formatage +```js +import { fmtEUR, fmtPct, fmtDate, fmtStatut, memberLabel, today } from '../utils/format.js'; +``` + +### Composants modaux +Utiliser `…} width={680}>`. + +### Gestion des erreurs form +Pattern : `const [err, setErr] = useState(null)` + `{err &&
{err}
}`. + +### Classes CSS utiles +- `.kpi` / `.kpi-grid` — cartes KPI +- `.card` — conteneur avec fond et bordure +- `.topbar` — barre de titre de page +- `.account-layout` / `.account-sidebar` / `.account-content` — layout pages Settings/MonCompte/Admin +- `.dr-kpi-row` — ligne KPI style Remboursements/Dépôts +- `.dr-tabs` / `.dr-tab` — onglets +- `.badge` + `.en_cours` / `.rembourse` / `.en_retard` / `.procedure` — badges statut +- `.cat-badge` — badge catégorie plateforme +- `.display-toggle` / `.display-toggle-btn` / `.display-toggle-btn.active` — toggle Brut/Net topbar +- `[data-fontsize="large|medium|compact"]` — taille de police (attribut sur ``) +- `[data-theme="dark"]` — thème sombre + +### Thème CSS +Les variables sont sur `:root` (thème clair) et `[data-theme="dark"]` pour les overrides. Ne jamais utiliser de couleurs hardcodées — toujours `var(--primary)`, `var(--success)`, `var(--danger)`, `var(--text)`, `var(--text-muted)`, `var(--border)`, `var(--surface-2)` etc. + +--- + +## Types de remboursement + +| Valeur | Label | +|---|---| +| `in_fine` | Prêt in fine (intérêts périodiques, capital à la fin) | +| `amortissable` | Prêt amortissable (capital + intérêts chaque période) | +| `differe` | Prêt différé (versement unique à l'échéance) | + +Fréquences : `mensuel` | `trimestriel` | `in_fine` (pour différé). + +Statuts investissement : `en_cours` | `rembourse` | `en_retard` | `procedure` | `cloture`. + +--- + +## Imports de données + +Module `ImportsSection` dans `Settings.jsx`. Supporte CSV/XLS pour : `plateformes`, `investissements`, `remboursements`, `depots_retraits`. Backend : `/api/imports` — parse, valide, insère. + +--- + +## Fonctionnalités admin + +Accessibles uniquement avec `user.role === 'admin'` : +- Page `/admin` : gestion utilisateurs, création compte, logs jobs +- Route backend `/api/admin` protégée par `requireAdmin` middleware +- Lien "Administration" dans UserMenu visible uniquement si `isAdmin` + +--- + +## Points d'attention pour futures modifications + +1. **Brut/Net** : toute nouvelle page affichant des intérêts doit importer `useUi` et dériver `const netMode = displayMode === 'net'`. Appliquer la même logique que les pages existantes. + +2. **Migrations DB** : toujours ajouter dans `backend/src/db/index.js` avec guard `PRAGMA table_info()`. Ne pas modifier `schema.sql`. + +3. **Routes supprimées** : `/preferences` et `/imports` sont des redirects vers Settings. Ne pas les recréer. + +4. **InteretsChart** : le toggle Brut/Net interne a été **supprimé** — le composant reçoit `netMode` en prop depuis le parent, lui-même dérivé de `UiContext`. + +5. **Investisseur scope** : pour les vues "tous les investisseurs", passer `{ scope: 'all' }` à l'API. Le backend filtre par `user_id` via le JWT. + +6. **Calcul `net_recu_total`** : pour la page Investissements, le backend retourne `SUM(r.net_recu)` renommé `net_recu_total`. Ce champ inclut capital + cashback + intérêts nets — c'est une approximation de "rendement net" par investissement, pas uniquement les intérêts. + +7. **Modifications de schéma plateformes** : avant d'ajouter ou de modifier un champ sur `plateformes` ou `plateformes_referentiel`, toujours demander : *"Ce champ doit-il exister dans les deux tables ?"* Si oui, appliquer la migration sur les deux tables ET mettre à jour tous les mécanismes d'héritage : `HERITABLE_FIELDS` (plateformes.js), payload du `push` (referentiel.js), `computeOverridden`, formulaire Settings (dropdown référentiel + badge hérité), et route `reset`. diff --git a/MEMORY.md b/MEMORY.md new file mode 100644 index 0000000..ec81d12 --- /dev/null +++ b/MEMORY.md @@ -0,0 +1,384 @@ +# MEMORY.md — Crowdlending Tracker +*Dernière mise à jour: 2026-05-09 (session 6)* + +--- + +## Résumé du projet + +Application web **mono-utilisateur multi-investisseur** de suivi de crowdlending (prêts participatifs). Saisie manuelle + import Excel. Usage personnel/familial, non déployé publiquement. + +- **Backend** : Node.js / Express + SQLite (better-sqlite3), port 4000 +- **Frontend** : React 18 + Vite, port 5173 (dev) / 8080 (Docker) +- **Auth** : JWT 7j, header `Authorization: Bearer` +- **DB** : `backend/data/crowdlending.db` (WAL mode) +- **Docker** : `docker compose up -d --build` → frontend :8080, backend :4000 + +--- + +## État des migrations DB (dernière colonne ajoutée) + +Les migrations sont dans `backend/src/db/index.js` (pattern `PRAGMA table_info()` + `ALTER TABLE ADD COLUMN`). Ne jamais toucher `schema.sql` pour les évolutions. + +| Migration | Colonne / opération | +|---|---| +| investisseurs | `prenom TEXT`, `type TEXT` (famille/entreprise), `is_principal INTEGER` | +| remboursements | `cashback REAL`, `interets_nets REAL` (suppression `autres_taxes`) | +| investissements | rename `date_debut` → `date_premiere_echeance`, `date_echeance` → `date_cible`, `freq_interets TEXT`, `date_debut_simul TEXT` | +| users | `role TEXT` ('user'/'admin') | +| plateformes | `domiciliation TEXT`, `fiscalite TEXT`, `taux_fiscalite_locale REAL` | +| investissements | `categorie_id INTEGER` (FK → categories_plateforme, ON DELETE SET NULL) | +| remboursements | **Recréation de table** : `investissement_id` rendu nullable, ajout `bonus_plateforme_id`, `bonus_investisseur_id`, `type TEXT DEFAULT 'normal'` | +| plateformes | `methode_remboursement TEXT NOT NULL DEFAULT 'portefeuille'` | +| remboursements | `methode_remboursement TEXT NOT NULL DEFAULT 'portefeuille'` | +| depots_retraits | `remboursement_id INTEGER` (lien vers le remboursement source pour les retraits auto) | + +--- + +## Fonctionnalités clés actuelles + +### Toggle Brut/Net global +- Géré via `UiContext.displayMode` ('net' | 'brut'), persisté localStorage `cl_display_mode` +- Affiché dans la **topbar** (`Layout.jsx`) — widget `.display-toggle` +- Appliqué sur : Dashboard, Investissements, InvestissementDetail, Remboursements +- **Règle** : toute nouvelle page avec intérêts → `const netMode = displayMode === 'net'` +- Le toggle interne de `InteretsChart` a été **supprimé** — reçoit `netMode` en prop + +### Modèle fiscal +``` +interets_nets = interets_bruts - prelev_sociaux - prelev_forfaitaire +net_recu = capital + cashback + interets_nets +PFU France = 30% (17.2% PS + 12.8% IR) +``` +Estimation nette projections : `interets_bruts * (1 - (prelev_sociaux + impot_revenu) / 100)` + +### Modèle plateforme (évolution récente) +- `domiciliation` : `france` | `zone_europeenne` | `hors_zone_europeenne` +- `fiscalite` : `flat_tax` | `sans_fiscalite_locale` | `avec_fiscalite_locale` +- `methode_remboursement` : `portefeuille` | `compte_courant` | `choix_investisseur` + - `portefeuille` (défaut) : remboursement sur le porte-monnaie de la plateforme (méthode fermée) + - `compte_courant` : remboursement sur le compte courant de l'investisseur (méthode fermée) + - `choix_investisseur` : l'investisseur choisit sur la plateforme (méthode ouverte) +- Règle : si `domiciliation === 'france'` → `fiscalite` forcé à `flat_tax`, `taux_fiscalite_locale` null +- Helpers dans `Settings.jsx` : `applyDomiciliationChange`, `applyFiscaliteChange`, `fmtFiscalite`, `METHODE_REMB_LABELS` + +### Navigation +- Sidebar : 5 pages data uniquement (Dashboard, Investissements, Dépôts/Retraits, Remboursements, Fiscalité) +- Settings / MonCompte / Admin → **UserMenu popup** (coin bas gauche) +- Pages "compte" : layout `account-layout`, navigation par `?section=` URL param +- Routes dépréciées redirigées : `/preferences → /settings?section=apparence`, `/imports → /settings?section=imports` + +### Investisseur scope +- `activeView` : `'single'` | `'all'` +- Vue "tous" → passer `{ scope: 'all' }` à l'API +- Backend filtre toujours par `user_id` via JWT + +--- + +## Patterns à respecter + +### API calls +```js +api.get('/investissements', { scope: 'all' }) +api.post('/remboursements', payload) +api.put(`/investissements/${id}`, payload) +api.del(`/plateformes/${id}`) +``` + +### Formatage +```js +import { fmtEUR, fmtPct, fmtDate, fmtStatut, memberLabel, today } from '../utils/format.js' +``` + +### Erreurs form +```js +const [err, setErr] = useState(null) +// {err &&
{err}
} +``` + +### Modales +```jsx +…} width={680}> +``` + +### Pas de couleurs hardcodées +Toujours `var(--primary)`, `var(--success)`, `var(--danger)`, `var(--text)`, `var(--text-muted)`, `var(--border)`, `var(--surface-2)`, etc. + +--- + +## Types / énumérations de référence + +| Entité | Valeurs | +|---|---| +| `investissements.statut` | `en_cours` \| `rembourse` \| `en_retard` \| `procedure` \| `cloture` | +| `investissements.type_remb` | `in_fine` \| `amortissable` \| `differe` | +| `investissements.freq_interets` | `mensuel` \| `trimestriel` \| `in_fine` | +| `remboursements.type` | `normal` \| `bonus_parrainage` \| `bonus_plateforme` | +| `remboursements.statut` | `paye` \| `retard` \| `partiel` \| `impaye` | +| `depots_retraits.type` | `depot` \| `retrait` | +| `investisseurs.type` | `famille` \| `entreprise` | +| `users.role` | `user` \| `admin` | + +--- + +## Sections Settings + +| Section (`?section=`) | Contenu | +|---|---| +| `apparence` (défaut) | thème, police, langue, devise | +| `plateformes` | CRUD plateformes + catégories | +| `categories` | CRUD catégories de plateformes | +| `garanties` | référentiel garanties | +| `pfu` | taux PFU par année | +| `notation` | critères notation par plateforme | +| `imports` | import CSV/XLS | + +--- + +## Endpoints API principaux + +``` +POST /api/auth/register | login | me +GET/POST/PUT/DELETE /api/investisseurs +GET/POST/PUT/DELETE /api/plateformes +GET/POST/PUT/DELETE /api/depots-retraits +GET/POST/PUT/DELETE /api/investissements +GET/POST/PUT/DELETE /api/remboursements +GET/POST/DELETE /api/simul +POST /api/simul/generate +GET /api/dashboard +GET /api/fiscal-2778?annee=YYYY +GET /api/fiscal-2778/export?annee=YYYY +GET/POST /api/pfu +GET/POST/PUT/DELETE /api/notation +GET/POST/PUT/DELETE /api/garanties +GET/POST/PUT/DELETE /api/categories +POST /api/imports/preview | apply +GET /api/imports/history +GET/POST/PUT/DELETE /api/admin/users (requireAdmin) +``` + +Headers requis (hors `/auth/*`) : `Authorization: Bearer ` + `X-Investisseur-Id: ` pour routes scopées. + +--- + +## Démarrage local + +```bash +# Backend +cd backend && npm install && npm run dev # :4000 + +# Frontend +cd frontend && npm install && npm run dev # :5173 (proxy /api → 4000) +``` + +Variables d'env : copier `.env.example` → `.env`, renseigner `JWT_SECRET`. + +--- + +## Fichiers clés à connaître + +| Fichier | Rôle | +|---|---| +| `backend/src/db/index.js` | Init DB + **toutes les migrations** | +| `backend/src/db/schema.sql` | Schéma de référence (CREATE TABLE) — ne pas modifier pour les évolutions | +| `frontend/src/api.js` | Client HTTP centralisé (gère JWT) | +| `frontend/src/utils/format.js` | `fmtEUR`, `fmtDate`, `fmtPct`, `fmtStatut`, `memberLabel`, `today` | +| `frontend/src/context/UiContext.jsx` | `displayMode` (brut/net), sidebar, fontScale, langue, devise | +| `frontend/src/components/Layout.jsx` | Shell app, topbar, toggle Brut/Net | +| `frontend/src/pages/Settings.jsx` | Hub paramètres, helpers fiscalité plateforme | + +--- + +## Layout deux colonnes — pattern Settings (session 2026-05-08) + +Les sections **Plateformes** et **Catégories d'investissement** de `Settings.jsx` utilisent désormais le même pattern deux colonnes que `DepotsRetraits.jsx` : + +- Classes CSS réutilisées : `.dr-mouvements-layout`, `.dr-mouvements-list`, `.dr-mouvements-detail`, `.dr-row`, `.dr-row-selected`, `.dr-detail`, `.dr-detail-empty`, `.dr-detail-title`, `.dr-detail-fields`, `.dr-detail-field`, `.dr-detail-label`, `.dr-detail-value`, `.dr-detail-footer`, `.dr-detail-edit-btn` +- **Plateformes** : tableau réduit (Nom + Catégories d'invest. + Nb invest.), panneau détail droite avec `PlatDetailPanel`, création via modale `showNewPlat`, **suppression dans la modale d'édition** (bouton "Supprimer" dans le footer gauche de la modale — logique inline avec `confirm()` + early return si annulé) +- **Catégories** : tableau réduit (Nom + Nb plateformes), panneau détail droite avec `CatDetailPanel`, création via modale `showNewCat`, suppression contextuelle dans le panneau (bouton rouge activé si non-utilisée, grisé si utilisée), export CSV/JSON +- Auto-sélection du premier item : `useEffect` avec `setSelectedX(prev => prev ? prev : items[0])` +- `nb_investissements` ajouté au backend dans `GET /api/plateformes` via sous-requête SQL (pas de nouvel endpoint) +- **Pas de PUT /api/categories** → pas de renommage de catégorie possible côté UI + +--- + +## CategorySelect — dropdown position:fixed (session 2026-05-08) + +`CategorySelect.jsx` a été réécrit pour corriger le clipping du dropdown dans les modales (`overflow: auto` clippe les descendants `position: absolute`). + +Solution : dropdown en `position: fixed` avec position calculée via `getBoundingClientRect()` : +- `triggerRef` sur le bouton déclencheur +- `useLayoutEffect` mesure `rect.bottom + 4` / `rect.left` / `rect.width` à chaque ouverture +- Dropdown rendu **en dehors du `.cat-select-wrap`** (comme sibling dans le `<>...`) avec `id="cat-select-dropdown-portal"` et `zIndex: 9999` +- Click-outside exclut à la fois `wrapRef` et le portal +- `scroll` (capture phase) + `resize` ferment le dropdown + +--- + +## Nomenclature — Catégories (session 2026-05-08) + +- **Labels utilisateur** : "Catégories d'investissement" (section Settings, modales, boutons, export) +- **Identifiants code** : inchangés (`categories`, `categorie_id`, `categories_plateforme`, `/api/categories`) +- Dans les tableaux/badges : abréviation "Catégories d'invest." acceptable + +--- + +## Catégorie d'investissement sur les investissements (session 2026-05-08) + +### Modèle de données +- Colonne `categorie_id INTEGER` ajoutée sur `investissements` (migration dans `db/index.js`), FK vers `categories_plateforme(id) ON DELETE SET NULL` +- Backend retourne `categorie_nom` via `LEFT JOIN categories_plateforme cp ON cp.id = i.categorie_id` dans `GET /investissements` et `GET /investissements/:id` +- Schema Zod : `categorie_id: z.number().int().positive().nullable().optional()` + +### Logique de sélection par défaut (à respecter partout) +```js +// 0 catégories liées à la plateforme → CategorySelect libre (toutes cats, mode single-select) +// 1 catégorie → auto-sélectionnée, select disabled +// 2+ catégories → select activé, défaut = la plus utilisée dans rows (user-wide), sinon première +function defaultCategorieId(platId, plats, rows = []) { + const plat = plats.find(p => p.id === Number(platId)); + if (!plat || !plat.categories || plat.categories.length === 0) return ''; + if (plat.categories.length === 1) return plat.categories[0].id; + const counts = {}; + for (const r of rows) { + if (r.categorie_id && plat.categories.some(c => c.id === r.categorie_id)) + counts[r.categorie_id] = (counts[r.categorie_id] || 0) + 1; + } + if (Object.keys(counts).length > 0) + return Number(Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0]); + return plat.categories[0].id; +} +``` +- Dans `InvestissementDetail.jsx` : version sans `rows` (pas de liste disponible), 2+ → première de la liste +- `InvestissementDetail.defaultCategorieId` : signature `(platId, plats)` seulement + +### Ouverture de modale édition — règle critique +**Toujours** initialiser `categorie_id` avec `row.categorie_id || defaultCategorieId(...)` et NON `row.categorie_id || ''`. Sans ça, les anciens investissements (categorie_id null en base) affichent la bonne catégorie visuellement (select disabled sur 1 seul choix) mais envoient `null` au backend car l'état React reste à `''`. + +### CategorySelect en mode single-select +Quand CategorySelect est utilisé pour un champ à valeur unique (ex : categorie_id) : +```jsx +selected={form.categorie_id ? [Number(form.categorie_id)] : []} +onChange={ids => setForm(f => ({ ...f, categorie_id: ids[ids.length - 1] ?? '' }))} +``` +`ids[ids.length - 1]` fonctionne car `toggle()` ajoute à la fin — unchecking donne `[]`, switching donne `[ancien, nouveau]`. + +### Fichiers modifiés +| Fichier | Changement | +|---|---| +| `backend/src/db/index.js` | Migration `categorie_id` sur `investissements` | +| `backend/src/routes/investissements.js` | Schema + GET/POST/PUT avec categorie_id/categorie_nom | +| `frontend/src/pages/Investissements.jsx` | Import CategorySelect, état categories, defaultCategorieId, champ dynamique modale | +| `frontend/src/pages/InvestissementDetail.jsx` | Idem + affichage dans "Informations du projet" | + +--- + +## Bonus Parrainage / Bonus Plateforme (session 3 — 2026-05-08) + +### Modèle de données +- `remboursements.type` : `'normal'` | `'bonus_parrainage'` | `'bonus_plateforme'` +- Pour type `normal` : `investissement_id` obligatoire, `bonus_plateforme_id` null +- Pour les bonus : `bonus_plateforme_id` obligatoire, `investissement_id` null, `bonus_investisseur_id` pour le scope +- Seul champ saisi pour les bonus : `cashback` (stocké aussi dans `net_recu`) + +### Frontend — sentinelles dans la modale Remboursements +```js +const BONUS_VALUES = ['BONUS_PARRAINAGE', 'BONUS_PLATEFORME']; +const BONUS_TYPE_MAP = { BONUS_PARRAINAGE: 'bonus_parrainage', BONUS_PLATEFORME: 'bonus_plateforme' }; +const isBonus = BONUS_VALUES.includes(form.investissement_id); +``` +- Options ajoutées au select investissement : `value="BONUS_PARRAINAGE"` et `value="BONUS_PLATEFORME"` +- Quand `isBonus` : affiche select plateforme (requis) + select investisseur optionnel (vue 'all'), masque capital/intérêts/prélèvements +- Payload envoyé : `{ type, bonus_plateforme_id, bonus_investisseur_id, date_remb, cashback, statut, notes }` +- `openEdit` : mappe `r.type` → sentinel pour pré-remplir le select + +### Backend — route remboursements +- GET : `LEFT JOIN investissements` + `COALESCE` pour résoudre `plateforme_id` et `investisseur_id` +- **Alias critique** : `COALESCE(i.plateforme_id, r.bonus_plateforme_id) AS plateforme_id` (pas `resolved_plateforme_id` — le frontend utilise `r.plateforme_id` partout pour l'agrégation et les filtres) +- `nom_projet` : `CASE r.type WHEN 'bonus_parrainage' THEN '— Bonus Parrainage' WHEN 'bonus_plateforme' THEN '— Bonus Plateforme'` +- `capital_restant_du` : correlated subquery (pas window function) pour gérer les NULL +- Vue `v_interets_annuels` : filtre `AND r.type = 'normal'` pour exclure les bonus + +### Migration DB (db/index.js) +- Recréation complète de la table `remboursements` via table temporaire `__repair_remboursements` +- Guard : `!rembColsBonus.includes('bonus_plateforme_id')` +- `PRAGMA foreign_keys = OFF` avant, `ON` après +- Drop/recreate de la vue `v_interets_annuels` (qui référence `investissement_id`) + +--- + +## Problèmes de compatibilité Node.js rencontrés (session 3) + +- **`||=` (logical OR assignment)** dans `plateformes.js` causait `SyntaxError: Unexpected token ']'` sur Node v20.17.0 → remplacé par `if (!map[x]) map[x] = []` +- **`db/index.js` tronqué** : les outils Edit/Write et `cat >>` tronquent parfois les fichiers longs. Solution fiable : reconstruire avec `python3` (lecture + écriture du fichier entier) +- **Read tool vs disque** : le Read tool peut afficher du contenu en cache qui ne correspond pas au fichier réel — toujours vérifier avec `bash tail` ou `wc -l` +- **Node v22.22** : version actuelle après upgrade depuis v20.17 — résout les problèmes de syntaxe +- **Octets nuls en fin de fichier** : les opérations `cat >>` successives peuvent laisser des `\x00` à la fin d'un fichier → `SyntaxError: Invalid or unexpected token` à la ligne N+1. Fix : `python3 -c "content=open(f,'rb').read(); open(f,'wb').write(content.rstrip(b'\\x00'))"` + +--- + +## Corrections session 4 — Remboursements (2026-05-09) + +### Page Remboursements — onglet "Remboursements" +- **Colonne "Type" ajoutée** au tableau : badge "Parrainage" (bleu, `var(--primary)`) pour `bonus_parrainage`, badge "Bonus" (vert, `var(--success)`) pour `bonus_plateforme`. Colonnes Capital/Intérêts/Prélèvements affichent `—` pour les bonus. +- **Filtre "Type" ajouté** dans la barre de filtres : Tous / Remboursements / Bonus Parrainage / Bonus Plateforme. Désactive le filtre "Projet" quand un type bonus est sélectionné. +- **Logique filtre `tableRows`** : quand un filtre projet est actif, les lignes bonus sont exclues (comportement explicite). Quand un filtre type bonus est actif, le filtre projet est ignoré. +- **Modal "Nouveau remboursement"** : suppression du blocage `investissementsActifs.length > 0` — la modale s'ouvre toujours, même si tous les investissements sont remboursés (utile pour saisir un bonus cashback). + +### Bug investisseur — nom dupliqué dans les modales +- **Cause** : `inv.nom` contient déjà le nom complet (ex. "Olivier CROGUENNEC"), et `inv.prenom` vaut "Olivier". La concaténation `${inv.prenom} ${inv.nom}` donnait "Olivier Olivier CROGUENNEC". +- **Fix** : utiliser `memberLabel(inv)` (import depuis `../utils/format.js`) à la place de la concaténation manuelle. `memberLabel` retourne simplement `m.nom`. +- **Règle à retenir** : toujours utiliser `memberLabel(inv)` pour afficher le nom d'un investisseur dans les options/selects — jamais `inv.prenom + inv.nom`. + +--- + +## Session 6 — UX et calculs (2026-05-09) + +### Barre de recherche rapide de projets (Layout.jsx) +- Composant `ProjectSearch` ajouté dans la topbar globale (`Layout.jsx`) +- Ctrl+K / Cmd+K → focus sur la barre +- Résultats filtrés (max 8) sur `nom_projet` et `plateforme_nom`, navigation ↑↓ Enter Escape +- Clic ou Enter → navigate vers `/investissements/${inv.id}` +- Classes CSS : `.project-search-wrap` (border primary, border-radius 20px, min-width 330px), `.project-search-input` (outline:none, box-shadow:none), `.project-search-dropdown`, `.project-search-item`, `.project-search-item-name`, `.project-search-item-meta`, `.project-search-kbd`, `.project-search-clear` +- `STATUT_LABELS` dans `ProjectSearch` : `{ en_cours:'en cours', rembourse:'remboursé', en_retard:'en retard', procedure:'procédure', cloture:'clôturé' }` — obligatoire pour avoir les accents corrects + +### Création investissement → redirection automatique vers le détail +- Dans `Investissements.jsx`, après `api.post('/investissements', payload)` : `navigate(`/investissements/${created.id}`)` (au lieu d'un reload) + +### KPI dynamiques (année/plateforme) — pattern unifié +- **Architecture** : `allRows` → `kpiRows` (filtre plateforme + `platYear`/`rembPlatYear`/`drPlatYear`, sans filtre statut/type) → `chartRows` (+ statut/type) → `rows` (+ mois) +- `totals` calculés depuis `kpiRows` pour que les KPI restent dynamiques +- `platYear` / `rembPlatYear` / `drPlatYear` : états indépendants de `filter.year` — pour le sélecteur d'année du tableau par plateforme uniquement +- **DepotsRetraits** : `kpiSoldePortefeuille` calculé live depuis backend (sans filtre année) OU cumulatif depuis `allRows + allRemb` jusqu'à `${drPlatYear}-12-31` (avec filtre année, inclut les remboursements `methode=portefeuille`) +- KPI sur une seule ligne `.dr-kpi-row` en flexbox (`.dr-kpi-row > .kpi { flex: 1 }`) — s'adapte à n'importe quel nombre de KPI + +### Bouton "+" dans InvestissementDetail — header "Remboursements enregistrés" +- Bouton `.btn-icon` à droite du titre (flex justify-content: space-between) +- Appelle `openNewRemb()` : pré-remplit date du jour + methode_remboursement de la plateforme, ouvre la modale de saisie +- CSS `.btn-icon` : 32×32px, border-radius 50%, color var(--text), hover → background var(--surface-2) + color var(--primary). `.btn-icon svg { width:18px; height:18px }` — toujours dimensionner via CSS, pas via attributs SVG width/height + +### Taux PFU — fallback dernière année connue +- **Avant** : si année absente de la table PFU → 0% (bug projections futures) +- **Après** : utiliser `getLastKnownRates()` = `pfuRates.reduce((best, r) => r.annee > best.annee ? r : best, pfuRates[0])` +- Corrigé dans : `Dashboard.jsx` (`getPfuReduction`), `InvestissementDetail.jsx` (`getRatesForYear`), `Remboursements.jsx` (`getRatesForYear` + `getPfuReduction`) + +### Première période partielle (mois incomplet) — adjustFirstPartialPeriod +- Ajouté dans `backend/src/utils/schedule.js` +- Appelé à la fin de `adjustSimulForActuals` (après le recalcul capital ET après `generateSimul` pour le cas `total_capital <= 0`) +- **Logique** : trouve le 1er remboursement réel avec `interets_bruts > 0` → cherche l'entrée simul du même mois YYYY-MM → si `interets_prevus (simul) > interets_bruts (réel)` d'au moins 0,001 € → met à jour la première ligne avec le montant réel + reporte la différence sur la dernière échéance +- **Idempotent** : si les valeurs sont déjà alignées, aucune modification +- **Ne s'applique pas** aux prêts `differe` (versement unique, pas de période partielle) + +--- + +## Pièges / points d'attention + +1. **`net_recu_total`** dans Investissements = `SUM(r.net_recu)` = capital + cashback + intérêts nets — c'est une approximation du rendement net, pas uniquement les intérêts. +2. **XIRR** : calculé uniquement sur `statut === 'rembourse'`. Flux bruts = capital + cashback + interets_bruts. Flux nets = net_recu. +3. **`InteretsChart`** n'a plus de toggle interne — toujours passer `netMode` en prop depuis le parent. +4. **Migrations DB** : guard `PRAGMA table_info()` obligatoire pour éviter les erreurs si colonne déjà présente. +5. **Admin** : middleware `requireAdmin` sur `/api/admin`. Lien visible dans UserMenu uniquement si `isAdmin`. +6. **Job auto-statut** : `backend/src/jobs/autoStatut.js` — met à jour les statuts investissements automatiquement. +7. **Modal + dropdown** : ne jamais utiliser `position: absolute` pour un dropdown dans une modale — utiliser `position: fixed` + `getBoundingClientRect()` (cf. `CategorySelect.jsx`). +8. **Suppression dans modale** : ne pas chaîner `.then(() => closeModal())` sur une fonction qui appelle `confirm()` — inliner la logique avec early return si `!confirm(...)` pour éviter la fermeture sur "Annuler". +9. **Affichage investisseur** : toujours `memberLabel(inv)` — jamais `inv.prenom + ' ' + inv.nom` car `nom` contient déjà le nom complet. diff --git a/README.md b/README.md index b6885c1..83418c4 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,192 @@ -# crowdlending-app +# Crowdlending Tracker + +Application web de suivi de crowdlending : dépôts/retraits, investissements, remboursements (réels & simulés), tableau de bord global et récapitulatif fiscal **2778-SD**. + +**Stack** : React (Vite) + Node.js / Express + SQLite (better-sqlite3) + Docker. + +## Modules + +1. **Dépôts / Retraits** — mouvements de cash par plateforme. +2. **CF Investissements** — liste des projets souscrits (montant, taux, durée, statut). +3. **Remboursements** — échéances réellement perçues, avec ventilation fiscale. +4. **Simul Remboursements** — échéancier prévisionnel (in fine, amortissable, mensuel, différé). +5. **TdB Global** — KPI cumulés, soldes par plateforme, intérêts par année, échéances à venir, projets en défaut. +6. **2778-SD** — récap annuel : intérêts, prélèvements sociaux, PFNL, pertes en capital, **export CSV**. + +Chaque module supporte la **saisie manuelle** et l'**import Excel** (avec mappage de colonnes). + +## Authentification + +JWT, multi-utilisateurs. Chaque user peut gérer plusieurs **comptes investisseurs** (Monsieur, Madame, SCI…) — toutes les données sont scopées au compte actif (sélecteur en haut à droite). + +## Interface + +- **Menu masquable** : un bouton ☰ en haut de la barre d'outils permet de masquer / réafficher la sidebar (état persisté). Sur mobile, la sidebar est masquée par défaut et s'ouvre en overlay. +- **Thème clair / sombre / système** : sélecteur en haut à droite (☀ Clair, ☾ Sombre, ◐ Système). Le mode "Système" suit la préférence OS et réagit en temps réel quand vous changez le thème système. Choix persisté dans le navigateur. + +## Démarrage rapide (Docker) + +```bash +cp .env.example .env # éditez JWT_SECRET +docker compose up -d --build +``` + +- Frontend : http://localhost:8080 +- Backend : http://localhost:4000/api/health + +Les données SQLite et les uploads Excel sont persistés dans des volumes Docker (`backend_data`, `backend_uploads`). + +## Démarrage en local (dev) + +### Backend +```bash +cd backend +npm install +cp ../.env.example .env +npm run db:init # crée le schéma SQLite +npm run dev # http://localhost:4000 +``` + +### Frontend +```bash +cd frontend +npm install +npm run dev # http://localhost:5173 (proxie /api -> 4000) +``` + +## Premier usage + +1. Allez sur `/register` et créez votre compte. Un compte investisseur "Compte principal" est créé automatiquement. +2. **Paramètres** → ajoutez vos plateformes (ClubFunding, October, La Première Brique, …). +3. **Paramètres** → ajoutez d'autres comptes investisseurs si besoin (Madame, SCI…). +4. **Dépôts / Retraits** → saisissez vos versements initiaux. +5. **CF Investissements** → ajoutez vos projets (avec taux + durée pour pouvoir simuler). +6. **Simul Remboursements** → choisissez un investissement et "Générer" pour créer l'échéancier. +7. **Remboursements** → saisissez les échéances reçues au fil de l'eau. +8. **2778-SD** → consultez le récap fiscal et exportez le CSV au moment de la déclaration. + +## Import Excel + +Module **Import Excel** dans le menu : +1. Choisir le module cible (Dépôts/Retraits, Investissements, ou Remboursements). +2. Uploader le fichier `.xlsx` ou `.csv`. +3. Mapper chaque champ cible avec la colonne Excel correspondante (auto-détection sur les noms exacts). +4. Pour les champs non présents dans le fichier, fournir une valeur par défaut. +5. Valider — les lignes sont insérées en bloc (transaction), avec un récap des erreurs ligne par ligne. + +Les imports sont historisés (table `imports`). + +## Schéma de la base + +Voir `backend/src/db/schema.sql`. Tables principales : + +| Table | Rôle | +|-------|------| +| `users` | comptes de connexion | +| `investisseurs` | profils d'investissement (multi-comptes) | +| `plateformes` | ClubFunding, October, … | +| `depots_retraits` | mouvements de cash | +| `investissements` | projets souscrits | +| `remboursements` | échéances réellement perçues | +| `simul_remboursements` | échéancier prévisionnel | +| `imports` | historique des imports | + +Vues : `v_solde_plateforme`, `v_synthese_inv`, `v_interets_annuels`. + +## Structure du projet + +``` +crowdlending-app/ +├── backend/ +│ ├── src/ +│ │ ├── server.js # Express bootstrap +│ │ ├── db/ +│ │ │ ├── schema.sql # schéma SQLite +│ │ │ ├── index.js # connexion + apply schema +│ │ │ └── init.js # script `npm run db:init` +│ │ ├── middleware/ +│ │ │ ├── auth.js # JWT +│ │ │ ├── investisseurScope.js +│ │ │ └── errorHandler.js +│ │ └── routes/ +│ │ ├── auth.js +│ │ ├── investisseurs.js +│ │ ├── plateformes.js +│ │ ├── depotsRetraits.js +│ │ ├── investissements.js +│ │ ├── remboursements.js +│ │ ├── simul.js # CRUD + générateur d'échéancier +│ │ ├── dashboard.js +│ │ ├── fiscal2778.js # récap + export CSV +│ │ └── imports.js # preview + apply +│ ├── Dockerfile +│ └── package.json +├── frontend/ +│ ├── src/ +│ │ ├── main.jsx # bootstrap React +│ │ ├── App.jsx # router +│ │ ├── api.js # fetch wrapper +│ │ ├── styles.css +│ │ ├── context/ # AuthContext, InvestisseurContext +│ │ ├── components/ # Layout, Modal +│ │ ├── pages/ # Login/Register + 6 modules + Imports + Settings +│ │ └── utils/format.js +│ ├── Dockerfile +│ ├── nginx.conf +│ ├── vite.config.js +│ └── package.json +├── docker-compose.yml +├── .env.example +├── .gitignore +└── README.md +``` + +## Endpoints API (sélection) + +| Méthode | Endpoint | Description | +|---------|----------|-------------| +| POST | `/api/auth/register` | crée user + compte principal | +| POST | `/api/auth/login` | retourne JWT | +| GET | `/api/auth/me` | user courant | +| GET/POST/PUT/DELETE | `/api/investisseurs` | gérer les comptes investisseurs | +| GET/POST/PUT/DELETE | `/api/plateformes` | gérer les plateformes | +| GET/POST/PUT/DELETE | `/api/depots-retraits` | mouvements de cash | +| GET/POST/PUT/DELETE | `/api/investissements` | projets | +| GET | `/api/investissements/:id` | détail + remboursements + simul | +| GET/POST/PUT/DELETE | `/api/remboursements` | échéances réelles | +| GET/POST/DELETE | `/api/simul` | échéances prévisionnelles | +| POST | `/api/simul/generate` | générer l'échéancier d'un investissement | +| GET | `/api/dashboard` | TdB global | +| GET | `/api/fiscal-2778?annee=2025` | récap fiscal | +| GET | `/api/fiscal-2778/export?annee=2025` | export CSV | +| POST | `/api/imports/preview` | analyse fichier Excel | +| POST | `/api/imports/apply` | insère les lignes selon mappage | +| GET | `/api/imports/history` | historique des imports | + +Tous les endpoints (sauf `/auth/*`) requièrent : +- header `Authorization: Bearer ` +- header `X-Investisseur-Id: ` pour les routes scopées (depots-retraits, investissements, remboursements, simul, dashboard, fiscal-2778, imports). + +## Notes fiscales 2778-SD + +Les cases calculées (2TR, 2CK, 2BH) sont **indicatives**. La logique est : + +- **2TR** : somme des `interets_bruts` des remboursements payés/partiels de l'année. +- **2CK** : somme des `prelev_forfaitaire` (PFNL 12,8 % déjà retenu à la source par la plateforme) — c'est un crédit d'impôt. +- **2BH** : base CSG/CRDS (par défaut = intérêts bruts ; à ajuster selon votre situation). +- **Pertes en capital** : `montant_investi - capital_remboursé` pour les projets passés en `defaut` ou `cloture` dans l'année (`updated_at`). Vérifier l'éligibilité à l'imputation (CGI art. 125-00 A). + +L'export CSV inclut le détail ligne par ligne, prêt à être joint à votre déclaration. + +## Sécurité — TODO si déploiement public + +- Forcer un `JWT_SECRET` long et aléatoire en prod. +- Ajouter HTTPS (reverse proxy Caddy/Traefik). +- Activer un CORS explicite (origin exacte). +- Ajouter une politique de rotation de tokens / refresh tokens. +- Sauvegarde régulière du volume `backend_data`. + +## Licence + +Privé / personnel. -Application de Crowdlending \ No newline at end of file diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..77c6c87 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,7 @@ +node_modules +data +uploads +.env +.env.* +npm-debug.log +*.log diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..7d596b6 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,16 @@ +FROM node:20-bookworm-slim AS deps +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 build-essential \ + && rm -rf /var/lib/apt/lists/* +COPY package.json package-lock.json* ./ +RUN npm install --omit=dev + +FROM node:20-bookworm-slim +WORKDIR /app +ENV NODE_ENV=production +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN mkdir -p /app/data /app/uploads +EXPOSE 4000 +CMD ["node", "src/server.js"] diff --git a/backend/diag.mjs b/backend/diag.mjs new file mode 100644 index 0000000..c708042 --- /dev/null +++ b/backend/diag.mjs @@ -0,0 +1,72 @@ +/** + * Diagnostic rapide de la base de données crowdlending + * Exécuter depuis D:\dev\crowdlending-app\backend : + * node diag.mjs + */ +import Database from 'better-sqlite3'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DB_PATH = path.resolve(__dirname, 'data/crowdlending.db'); + +const db = new Database(DB_PATH, { readonly: true }); + +console.log('\n=== COMPTES PAR TABLE ==='); +for (const t of ['users','investisseurs','investissements','plateformes','remboursements','simul_remboursements','depots_retraits','investissement_historique']) { + try { + const n = db.prepare(`SELECT COUNT(*) AS n FROM "${t}"`).get().n; + console.log(` ${t.padEnd(30)} ${n} lignes`); + } catch (e) { + console.log(` ${t.padEnd(30)} ERREUR: ${e.message}`); + } +} + +console.log('\n=== TABLES __repair_* RESTANTES ==='); +const repairTables = db.prepare("SELECT name FROM sqlite_master WHERE name LIKE '__repair_%'").all(); +if (repairTables.length === 0) console.log(' (aucune)'); +else repairTables.forEach(r => console.log(` ${r.name}`)); + +console.log('\n=== USERS ==='); +db.prepare('SELECT id, email FROM users').all().forEach(r => + console.log(` user id=${r.id} email=${r.email}`) +); + +console.log('\n=== INVESTISSEURS ==='); +db.prepare('SELECT id, nom, user_id FROM investisseurs').all().forEach(r => + console.log(` investisseur id=${r.id} nom="${r.nom}" user_id=${r.user_id}`) +); + +console.log('\n=== INVESTISSEMENTS (5 premiers) ==='); +const invs = db.prepare('SELECT id, nom_projet, investisseur_id, statut FROM investissements LIMIT 5').all(); +if (invs.length === 0) console.log(' (TABLE VIDE!)'); +else invs.forEach(r => + console.log(` inv id=${r.id} investisseur_id=${r.investisseur_id} statut=${r.statut} projet="${r.nom_projet}"`) +); + +console.log('\n=== VÉRIFICATION FK investissements → investisseurs ==='); +const broken = db.prepare(` + SELECT COUNT(*) AS n FROM investissements i + WHERE NOT EXISTS (SELECT 1 FROM investisseurs v WHERE v.id = i.investisseur_id) +`).get().n; +console.log(broken === 0 ? ' OK (pas de FK cassée)' : ` ⚠️ ${broken} investissements avec investisseur_id introuvable!`); + +console.log('\n=== VÉRIFICATION FK remboursements → investissements ==='); +const broken2 = db.prepare(` + SELECT COUNT(*) AS n FROM remboursements r + WHERE NOT EXISTS (SELECT 1 FROM investissements i WHERE i.id = r.investissement_id) +`).get().n; +console.log(broken2 === 0 ? ' OK' : ` ⚠️ ${broken2} remboursements avec investissement_id introuvable!`); + +console.log('\n=== CHECK CONSTRAINT de investissements ==='); +const schemaInv = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='investissements'").get()?.sql ?? ''; +const checkLine = schemaInv.split('\n').find(l => l.includes('statut') && l.includes('CHECK')); +console.log(' ', checkLine?.trim() ?? '(non trouvé)'); + +console.log('\n=== RÉFÉRENCES _investissements_old RESTANTES ==='); +const refs = db.prepare("SELECT type, name FROM sqlite_master WHERE sql LIKE '%_investissements_old%'").all(); +if (refs.length === 0) console.log(' (aucune) ✓'); +else refs.forEach(r => console.log(` [${r.type}] ${r.name}`)); + +db.close(); +console.log('\nDiagnostic terminé.\n'); diff --git a/backend/fix_dates_cible.mjs b/backend/fix_dates_cible.mjs new file mode 100644 index 0000000..edf5ba4 --- /dev/null +++ b/backend/fix_dates_cible.mjs @@ -0,0 +1,67 @@ +/** + * fix_dates_cible.mjs + * Corrige les date_cible aberrantes (>2100) en recalculant date_souscription + duree_mois + * et régénère les simul_remboursements correspondantes. + * Usage : node fix_dates_cible.mjs + */ + +import Database from 'better-sqlite3'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { generateSimul } from './src/utils/schedule.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DB_PATH = process.env.DB_PATH || path.resolve(__dirname, 'data/crowdlending.db'); + +const db = new Database(DB_PATH); +db.pragma('journal_mode = WAL'); +db.pragma('foreign_keys = ON'); + +function addMonths(isoDate, months) { + const [y, m, d] = isoDate.split('-').map(Number); + const dt = new Date(Date.UTC(y, m - 1 + months, d)); + return dt.toISOString().slice(0, 10); +} + +const rows = db.prepare(` + SELECT i.id, i.nom_projet, p.nom AS plateforme, + i.date_souscription, i.date_cible, i.duree_mois, + i.montant_investi, i.taux_interet, i.type_remb, i.freq_interets, + i.date_premiere_echeance, i.date_debut_simul, i.echeance_fin_de_mois + FROM investissements i + JOIN plateformes p ON p.id = i.plateforme_id + WHERE i.statut IN ('en_cours','en_retard','procedure') + AND i.type_remb = 'differe' + AND i.date_cible > '2100-01-01' +`).all(); + +console.log(`${rows.length} investissements à corriger\n`); + +const update = db.prepare(`UPDATE investissements SET date_cible=?, updated_at=datetime('now') WHERE id=?`); + +const fix = db.transaction(() => { + for (const row of rows) { + const newDate = addMonths(row.date_souscription, row.duree_mois); + update.run(newDate, row.id); + console.log(`[${row.id}] ${row.plateforme} — ${row.nom_projet}`); + console.log(` ${row.date_cible} → ${newDate}`); + + generateSimul(db, { + id: row.id, + montant_investi: row.montant_investi, + taux_interet: row.taux_interet, + duree_mois: row.duree_mois, + type_remb: row.type_remb, + freq_interets: row.freq_interets, + date_premiere_echeance: row.date_premiere_echeance, + date_debut_simul: row.date_debut_simul, + date_souscription: row.date_souscription, + echeance_fin_de_mois: row.echeance_fin_de_mois ?? 0, + }); + console.log(` ✓ simulation régénérée`); + } +}); + +fix(); +console.log('\nTerminé.'); +db.close(); diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..f370be7 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,2296 @@ +{ + "name": "crowdlending-backend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "crowdlending-backend", + "version": "0.1.0", + "dependencies": { + "bcryptjs": "^2.4.3", + "better-sqlite3": "^11.3.0", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "express-rate-limit": "^7.4.0", + "helmet": "^7.1.0", + "jsonwebtoken": "^9.0.2", + "morgan": "^1.10.0", + "multer": "^1.4.5-lts.1", + "sharp": "^0.34.5", + "xlsx": "^0.18.5", + "zod": "^3.23.8" + }, + "devDependencies": {} + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "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/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "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", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz", + "integrity": "sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "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/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/morgan": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", + "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==", + "license": "MIT", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.3.0", + "on-headers": "~1.1.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/morgan/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "1.4.5-lts.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", + "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", + "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.0.0", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.90.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.90.0.tgz", + "integrity": "sha512-pZNQT7UnYlMwMBy5N1lV5X/YLTbZM5ncytN3xL7CHEzhDN8uVe0u55yaPUJICIJjaCW8NrM5BFdqr7HLweStNA==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..053857b --- /dev/null +++ b/backend/package.json @@ -0,0 +1,28 @@ +{ + "name": "crowdlending-backend", + "version": "0.1.0", + "description": "Backend API for crowdlending tracker", + "main": "src/server.js", + "type": "module", + "scripts": { + "start": "node src/server.js", + "dev": "node --watch src/server.js", + "db:init": "node src/db/init.js", + "db:seed": "node src/db/seed.js" + }, + "dependencies": { + "bcryptjs": "^2.4.3", + "better-sqlite3": "^11.3.0", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "express-rate-limit": "^7.4.0", + "helmet": "^7.1.0", + "jsonwebtoken": "^9.0.2", + "morgan": "^1.10.0", + "multer": "^1.4.5-lts.1", + "sharp": "^0.34.5", + "xlsx": "^0.18.5", + "zod": "^3.23.8" + } +} diff --git a/backend/src/db/index.js b/backend/src/db/index.js new file mode 100644 index 0000000..d443bc9 --- /dev/null +++ b/backend/src/db/index.js @@ -0,0 +1,1678 @@ +import Database from 'better-sqlite3'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { generateSimul } from '../utils/schedule.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const DB_PATH = process.env.DB_PATH || path.resolve(__dirname, '../../data/crowdlending.db'); + +// Ensure data directory exists +fs.mkdirSync(path.dirname(DB_PATH), { recursive: true }); + +const db = new Database(DB_PATH); +db.pragma('journal_mode = WAL'); +db.pragma('foreign_keys = ON'); + +// ── CLEANUP PRIORITAIRE : tables __repair_* orphelines ─────────────────── +// Si un repair précédent a créé des tables __repair_X mais n'a pas pu +// finaliser le RENAME (interruption, crash), on le finit ici, avant tout. +{ + const repairTables = db.prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE '__repair_%'" + ).all().map(r => r.name); + + if (repairTables.length > 0) { + db.exec('PRAGMA foreign_keys = OFF'); + + // SQLite valide toutes les vues lors d'un RENAME TABLE. + // Les vues cassées (qui référencent _investissements_old ou des tables + // provisoirement absentes) doivent être supprimées avant les renommages. + db.exec('DROP VIEW IF EXISTS v_interets_annuels'); + db.exec('DROP VIEW IF EXISTS v_synthese_inv'); + + for (const repairName of repairTables) { + const originalName = repairName.slice('__repair_'.length); + const originalExists = db.prepare( + "SELECT COUNT(*) AS n FROM sqlite_master WHERE type='table' AND name=?" + ).get(originalName).n > 0; + if (originalExists) { + // Les deux coexistent : l'original est vide, le repair a les données. + const idxs = db.prepare( + "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name=? AND sql IS NOT NULL" + ).all(originalName); + for (const idx of idxs) db.exec(`DROP INDEX IF EXISTS "${idx.name}"`); + db.exec(`DROP TABLE "${originalName}"`); + } + db.exec(`ALTER TABLE "${repairName}" RENAME TO "${originalName}"`); + } + + // Recréer les index (idempotent IF NOT EXISTS) + const knownIndexes = [ + 'CREATE INDEX IF NOT EXISTS idx_remb_inv ON remboursements(investissement_id)', + 'CREATE INDEX IF NOT EXISTS idx_remb_date ON remboursements(date_remb)', + 'CREATE INDEX IF NOT EXISTS idx_remb_statut ON remboursements(statut)', + 'CREATE INDEX IF NOT EXISTS idx_simul_inv ON simul_remboursements(investissement_id)', + 'CREATE INDEX IF NOT EXISTS idx_simul_date ON simul_remboursements(date_prevue)', + '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)', + ]; + for (const sql of knownIndexes) db.exec(sql); + + // Recréer les vues avec les références correctes + db.exec(`CREATE VIEW IF NOT EXISTS v_interets_annuels AS + SELECT + i.investisseur_id, + substr(r.date_remb,1,4) AS annee, + SUM(r.interets_bruts) AS interets_bruts, + SUM(r.prelev_sociaux) AS prelev_sociaux, + SUM(r.prelev_forfaitaire) AS prelev_forfaitaire, + SUM(r.net_recu) AS net_recu + FROM remboursements r + JOIN investissements i ON i.id = r.investissement_id + WHERE r.statut IN ('paye','partiel') + GROUP BY i.investisseur_id, substr(r.date_remb,1,4)`); + + db.exec(`CREATE VIEW IF NOT EXISTS v_synthese_inv AS + SELECT + i.investisseur_id, + COUNT(*) AS nb_projets, + SUM(i.montant_investi) AS total_investi, + SUM(CASE WHEN i.statut='en_cours' THEN i.montant_investi ELSE 0 END) AS encours, + SUM(CASE WHEN i.statut='rembourse' THEN i.montant_investi ELSE 0 END) AS rembourse, + SUM(CASE WHEN i.statut IN ('en_retard','procedure') THEN i.montant_investi ELSE 0 END) AS en_defaut + FROM investissements i + GROUP BY i.investisseur_id`); + + db.exec('PRAGMA foreign_keys = ON'); + } +} + +// Apply schema on first run (idempotent thanks to IF NOT EXISTS) +const schemaSql = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8'); +db.exec(schemaSql); + +// ── RÉPARATION D'URGENCE (doit s'exécuter EN PREMIER) ──────────────────── +// SQLite 3.26+ a auto-mis-à-jour les FK des tables enfants lors d'un RENAME +// TABLE de investissements → _investissements_old. Après DROP TABLE orpheline, +// ces FK sont invalides. writable_schema est bloqué par better-sqlite3, donc +// on recrée chaque table cassée : DDL corrigé en JS → temp table → copy → rename. +{ + const hasOrphanTable = db.prepare( + "SELECT COUNT(*) AS n FROM sqlite_master WHERE name='_investissements_old'" + ).get().n > 0; + + const brokenTables = db.prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND sql LIKE '%_investissements_old%'" + ).all().map(r => r.name); + + if (hasOrphanTable || brokenTables.length > 0) { + db.exec('PRAGMA foreign_keys = OFF'); + + if (hasOrphanTable) { + db.exec('DROP TABLE IF EXISTS _investissements_old'); + } + + for (const tableName of brokenTables) { + const row = db.prepare( + "SELECT sql FROM sqlite_master WHERE type='table' AND name=?" + ).get(tableName); + if (!row) continue; + + // Corriger la référence FK cassée en JS (writable_schema indisponible) + const tempName = `__repair_${tableName}`; + const fixedDdl = row.sql + .replace(/_investissements_old/g, 'investissements') + .replace(new RegExp(`CREATE TABLE (?:"${tableName}"|${tableName})\\b`), `CREATE TABLE "${tempName}"`); + + // Colonnes réelles de la table cassée (pour le INSERT SELECT) + const cols = db.prepare(`PRAGMA table_info("${tableName}")`) + .all().map(c => `"${c.name}"`).join(', '); + + db.exec(`DROP TABLE IF EXISTS "${tempName}"`); + db.exec(fixedDdl); + db.exec(`INSERT INTO "${tempName}" (${cols}) SELECT ${cols} FROM "${tableName}"`); + + // Supprimer les index utilisateurs (ils seront recréés ci-dessous) + const idxs = db.prepare( + "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name=? AND sql IS NOT NULL" + ).all(tableName); + for (const idx of idxs) db.exec(`DROP INDEX IF EXISTS "${idx.name}"`); + + db.exec(`DROP TABLE "${tableName}"`); + db.exec(`ALTER TABLE "${tempName}" RENAME TO "${tableName}"`); + } + + // Recréer les index supprimés avec leurs tables + if (brokenTables.includes('remboursements')) { + db.exec('CREATE INDEX IF NOT EXISTS idx_remb_inv ON remboursements(investissement_id)'); + db.exec('CREATE INDEX IF NOT EXISTS idx_remb_date ON remboursements(date_remb)'); + db.exec('CREATE INDEX IF NOT EXISTS idx_remb_statut ON remboursements(statut)'); + } + if (brokenTables.includes('simul_remboursements')) { + db.exec('CREATE INDEX IF NOT EXISTS idx_simul_inv ON simul_remboursements(investissement_id)'); + db.exec('CREATE INDEX IF NOT EXISTS idx_simul_date ON simul_remboursements(date_prevue)'); + } + + db.exec('PRAGMA foreign_keys = ON'); + } + + // Corriger aussi les VUES dont le corps SQL référence _investissements_old + // (SQLite 3.26+ auto-met-à-jour les JOIN dans les vues lors d'un RENAME TABLE) + const brokenViews = db.prepare( + "SELECT name, sql FROM sqlite_master WHERE type='view' AND sql LIKE '%_investissements_old%'" + ).all(); + for (const view of brokenViews) { + const fixedSql = view.sql.replace(/_investissements_old/g, 'investissements'); + db.exec(`DROP VIEW IF EXISTS "${view.name}"`); + db.exec(fixedSql); + } +} + +// ── Migrations incrémentales (colonnes ajoutées après le schéma initial) ── +const invCols = db.prepare("PRAGMA table_info(investisseurs)").all().map(c => c.name); + +if (!invCols.includes('prenom')) { + db.exec('ALTER TABLE investisseurs ADD COLUMN prenom TEXT'); +} +if (!invCols.includes('type')) { + db.exec("ALTER TABLE investisseurs ADD COLUMN type TEXT NOT NULL DEFAULT 'famille'"); + // Backfill : PM / SCI / SCPI → entreprise, tout le reste → famille + db.exec("UPDATE investisseurs SET type = 'entreprise' WHERE type_fiscal IN ('PM','SCI','SCPI')"); +} + +// ── Migration : suppression de la colonne autres_taxes (devenue inutile) ── +const rembCols = db.prepare('PRAGMA table_info(remboursements)').all().map(c => c.name); +if (rembCols.includes('autres_taxes')) { + // SQLite exige que la vue qui référence la colonne soit supprimée avant DROP COLUMN + db.exec('DROP VIEW IF EXISTS v_interets_annuels'); + db.exec('ALTER TABLE remboursements DROP COLUMN autres_taxes'); + // Recrée la vue sans autres_taxes (version à jour du schéma) + db.exec(` + CREATE VIEW IF NOT EXISTS v_interets_annuels AS + SELECT + i.investisseur_id, + substr(r.date_remb,1,4) AS annee, + SUM(r.interets_bruts) AS interets_bruts, + SUM(r.prelev_sociaux) AS prelev_sociaux, + SUM(r.prelev_forfaitaire) AS prelev_forfaitaire, + SUM(r.net_recu) AS net_recu + FROM remboursements r + JOIN investissements i ON i.id = r.investissement_id + WHERE r.statut IN ('paye','partiel') + GROUP BY i.investisseur_id, substr(r.date_remb,1,4) + `); +} + +// ── Migration : renommage date_debut → date_premiere_echeance, date_echeance → date_cible ── +{ + const cols = db.prepare('PRAGMA table_info(investissements)').all().map(c => c.name); + 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 + 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 + // 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`); +} + +// ── Migration : type_remb 'mensuel' → 'amortissable' + ajout freq_interets ── +const invCols2 = db.prepare('PRAGMA table_info(investissements)').all().map(c => c.name); +db.exec("UPDATE investissements SET type_remb = 'amortissable' WHERE type_remb = 'mensuel'"); +if (!invCols2.includes('freq_interets')) { + db.exec("ALTER TABLE investissements ADD COLUMN freq_interets TEXT NOT NULL DEFAULT 'mensuel'"); + // Les prêts différés ont forcément une fréquence in_fine + db.exec("UPDATE investissements SET freq_interets = 'in_fine' WHERE type_remb = 'differe'"); +} + +// ── Migration : ajout cashback + interets_nets ──────────────────────── +const rembCols2 = db.prepare('PRAGMA table_info(remboursements)').all().map(c => c.name); +if (!rembCols2.includes('cashback')) { + db.exec('ALTER TABLE remboursements ADD COLUMN cashback REAL NOT NULL DEFAULT 0'); +} +if (!rembCols2.includes('interets_nets')) { + db.exec('ALTER TABLE remboursements ADD COLUMN interets_nets REAL NOT NULL DEFAULT 0'); + // Backfill depuis les colonnes existantes + db.exec('UPDATE remboursements SET interets_nets = ROUND(interets_bruts - prelev_sociaux - prelev_forfaitaire, 2)'); + // Recalcule net_recu avec la nouvelle formule (cashback=0 sur les anciens, donc inchangé) + db.exec('UPDATE remboursements SET net_recu = ROUND(capital + cashback + interets_nets, 2)'); +} + +// ── 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 + WHERE type_remb = 'differe' + AND date_cible IS NOT NULL + AND (date_premiere_echeance IS NULL OR date_premiere_echeance != date_cible) + `).get().n; + + if (needsFix > 0) { + db.exec(` + UPDATE investissements + SET date_premiere_echeance = date_cible + WHERE type_remb = 'differe' AND date_cible IS NOT NULL + `); + // 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, + date_premiere_echeance, date_souscription + FROM investissements + WHERE type_remb = 'differe' + `).all(); + for (const inv of differeInvs) { + generateSimul(db, inv); + } + } +} + +// ── Migration : ajout date_debut_simul (restructuration de prêt) ───── +{ + const invColsDs = db.prepare('PRAGMA table_info(investissements)').all().map(c => c.name); + if (!invColsDs.includes('date_debut_simul')) { + db.exec('ALTER TABLE investissements ADD COLUMN date_debut_simul TEXT'); + } +} + +// ── Migration : statut 'defaut' → 'en_retard' ──────────────────────── +// writable_schema est bloqué par better-sqlite3 → on recrée la table +// investissements avec le bon CHECK constraint (même stratégie que le bloc +// d'urgence). PRAGMA legacy_alter_table = ON empêche SQLite 3.26+ de +// réécrire les FK des tables enfants lors du RENAME final. +{ + const schemaInv = db.prepare( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='investissements'" + ).get()?.sql ?? ''; + + if (schemaInv.includes("'defaut'")) { + const tempName = '__repair_investissements'; + + const fixedDdl = schemaInv + .replace(/CREATE TABLE investissements\b/, `CREATE TABLE "${tempName}"`) + .replace(/'defaut'/g, "'en_retard'"); + + const colDefs = db.prepare('PRAGMA table_info(investissements)').all(); + const colNames = colDefs.map(c => `"${c.name}"`).join(', '); + // Convertit les éventuels statuts 'defaut' → 'en_retard' lors du SELECT + const selectList = colDefs.map(c => + c.name === 'statut' + ? `CASE WHEN statut = 'defaut' THEN 'en_retard' ELSE statut END AS "statut"` + : `"${c.name}"` + ).join(', '); + + const idxs = db.prepare( + "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='investissements' AND sql IS NOT NULL" + ).all(); + + db.exec('PRAGMA foreign_keys = OFF'); + db.exec(`DROP TABLE IF EXISTS "${tempName}"`); + db.exec(fixedDdl); + db.exec(`INSERT INTO "${tempName}" (${colNames}) SELECT ${selectList} FROM investissements`); + for (const idx of idxs) db.exec(`DROP INDEX IF EXISTS "${idx.name}"`); + db.exec('DROP TABLE investissements'); + // legacy_alter_table = ON : empêche la réécriture automatique des FK enfants + db.exec('PRAGMA legacy_alter_table = ON'); + db.exec(`ALTER TABLE "${tempName}" RENAME TO investissements`); + db.exec('PRAGMA legacy_alter_table = OFF'); + db.exec('PRAGMA foreign_keys = ON'); + + db.exec('CREATE INDEX IF NOT EXISTS idx_inv_inv ON investissements(investisseur_id)'); + db.exec('CREATE INDEX IF NOT EXISTS idx_inv_plat ON investissements(plateforme_id)'); + db.exec('CREATE INDEX IF NOT EXISTS idx_inv_statut ON investissements(statut)'); + db.exec('CREATE INDEX IF NOT EXISTS idx_inv_date ON investissements(date_souscription)'); + } + + // Corriger la vue v_synthese_inv si elle référence encore 'defaut' + const viewSql = db.prepare( + "SELECT sql FROM sqlite_master WHERE type='view' AND name='v_synthese_inv'" + ).get()?.sql ?? ''; + + if (viewSql.includes("'defaut'")) { + db.exec('DROP VIEW IF EXISTS v_synthese_inv'); + db.exec(`CREATE VIEW v_synthese_inv AS + SELECT + i.investisseur_id, + COUNT(*) AS nb_projets, + SUM(i.montant_investi) AS total_investi, + SUM(CASE WHEN i.statut='en_cours' THEN i.montant_investi ELSE 0 END) AS encours, + SUM(CASE WHEN i.statut='rembourse' THEN i.montant_investi ELSE 0 END) AS rembourse, + SUM(CASE WHEN i.statut IN ('en_retard','procedure') THEN i.montant_investi ELSE 0 END) AS en_defaut + FROM investissements i + GROUP BY i.investisseur_id`); + } + + // Migrer les données (idempotent : no-op si déjà fait ou aucun statut 'defaut') + db.exec("UPDATE investissements SET statut = 'en_retard' WHERE statut = 'defaut'"); +} + +// ── Migration : table historique des modifications d'investissement ── +db.exec(` + CREATE TABLE IF NOT EXISTS investissement_historique ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + investissement_id INTEGER NOT NULL REFERENCES investissements(id) ON DELETE CASCADE, + date_evenement TEXT NOT NULL DEFAULT (date('now')), + type_evenement TEXT NOT NULL DEFAULT 'modification', + changements TEXT NOT NULL DEFAULT '[]', + notes TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) +`); + +// ── Migration : rôle utilisateur ───────────────────────────────────────── +{ + const userCols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name); + if (!userCols.includes('role')) { + db.exec("ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'"); + // Le premier utilisateur enregistré (id le plus petit) devient administrateur + db.exec("UPDATE users SET role = 'admin' WHERE id = (SELECT MIN(id) FROM users)"); + } +} + +// ── Migration : table de logs des jobs automatiques ────────────────────── +db.exec(` + CREATE TABLE IF NOT EXISTS job_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_name TEXT NOT NULL, + run_at TEXT NOT NULL DEFAULT (datetime('now')), + status TEXT NOT NULL DEFAULT 'ok', + nb_changes INTEGER NOT NULL DEFAULT 0, + details TEXT, + error_msg TEXT + ) +`); +db.exec('CREATE INDEX IF NOT EXISTS idx_job_logs_run_at ON job_logs(run_at DESC)'); + +// ── Notation : critères par plateforme ─────────────────────────────── +db.exec(` + CREATE TABLE IF NOT EXISTS notation_criteres ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plateforme_id INTEGER NOT NULL REFERENCES plateformes(id) ON DELETE CASCADE, + nom TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'etoiles', + valeurs TEXT, + min_val REAL, + max_val REAL, + description TEXT, + ordre INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) +`); +db.exec('CREATE INDEX IF NOT EXISTS idx_notation_plat ON notation_criteres(plateforme_id, ordre)'); + +// ── Types de garanties ─────────────────────────────────────────────── +db.exec(` + CREATE TABLE IF NOT EXISTS garantie_types ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + libelle TEXT NOT NULL, + description TEXT, + ordre INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) +`); +db.exec('CREATE INDEX IF NOT EXISTS idx_garantie_types_user ON garantie_types(user_id, ordre)'); + +// ── Migration : colonne is_principal sur investisseurs ─────────────────── +{ + const invColsPrincipal = db.prepare('PRAGMA table_info(investisseurs)').all().map(c => c.name); + if (!invColsPrincipal.includes('is_principal')) { + db.exec('ALTER TABLE investisseurs ADD COLUMN is_principal INTEGER NOT NULL DEFAULT 0'); + // Backfill : le plus ancien investisseur de type 'famille' par user devient principal + db.exec(` + UPDATE investisseurs SET is_principal = 1 + WHERE id IN ( + SELECT MIN(id) FROM investisseurs WHERE type = 'famille' GROUP BY user_id + ) + `); + } +} + +// ── Migration : domiciliation / fiscalité sur plateformes ─────────────── +{ + const platCols = db.prepare('PRAGMA table_info(plateformes)').all().map(c => c.name); + if (!platCols.includes('domiciliation')) { + db.exec("ALTER TABLE plateformes ADD COLUMN domiciliation TEXT NOT NULL DEFAULT 'france'"); + } + if (!platCols.includes('fiscalite')) { + db.exec("ALTER TABLE plateformes ADD COLUMN fiscalite TEXT NOT NULL DEFAULT 'flat_tax'"); + } + if (!platCols.includes('taux_fiscalite_locale')) { + db.exec('ALTER TABLE plateformes ADD COLUMN taux_fiscalite_locale REAL'); + } + if (!platCols.includes('type_produit_fiscal')) { + db.exec("ALTER TABLE plateformes ADD COLUMN type_produit_fiscal TEXT NOT NULL DEFAULT '2TT'"); + } + // ── Table plateforme_tax_details ────────────────────────────────────────── + db.exec(` + CREATE TABLE IF NOT EXISTS plateforme_tax_details ( + id INTEGER PRIMARY KEY, + plateforme_id INTEGER NOT NULL REFERENCES plateformes(id) ON DELETE CASCADE, + annee INTEGER NOT NULL, + raison_sociale TEXT, + siret_n TEXT, + siret_n1 TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(plateforme_id, annee) + ) + `); + + if (!platCols.includes('methode_remboursement')) { + db.exec("ALTER TABLE plateformes ADD COLUMN methode_remboursement TEXT NOT NULL DEFAULT 'portefeuille'"); + } +} + +// ── Migration : methode_remboursement sur remboursements ───────────────────── +{ + const rembCols = db.prepare('PRAGMA table_info(remboursements)').all().map(c => c.name); + if (!rembCols.includes('methode_remboursement')) { + db.exec("ALTER TABLE remboursements ADD COLUMN methode_remboursement TEXT NOT NULL DEFAULT 'portefeuille'"); + } +} + +// ── Migration : remboursement_id sur depots_retraits (retrait auto) ─────────── +{ + const drCols = db.prepare('PRAGMA table_info(depots_retraits)').all().map(c => c.name); + if (!drCols.includes('remboursement_id')) { + db.exec('ALTER TABLE depots_retraits ADD COLUMN remboursement_id INTEGER'); + } +} + +// ── Seed taux_pfu si vide ───────────────────────────────────────────── +const pfuCount = db.prepare('SELECT COUNT(*) AS n FROM taux_pfu').get().n; +if (pfuCount === 0) { + const insertPfu = db.prepare( + 'INSERT OR IGNORE INTO taux_pfu (annee, pfu_total, impot_revenu, prelev_sociaux, csg, crds, solidarite) VALUES (?, ?, ?, ?, ?, ?, ?)' + ); + const seedPfu = db.transaction(() => { + const data = [ + [2018, 30.0, 12.8, 17.2, 9.2, 0.5, 7.5], + [2019, 30.0, 12.8, 17.2, 9.2, 0.5, 7.5], + [2020, 30.0, 12.8, 17.2, 9.2, 0.5, 7.5], + [2021, 30.0, 12.8, 17.2, 9.2, 0.5, 7.5], + [2022, 30.0, 12.8, 17.2, 9.2, 0.5, 7.5], + [2023, 30.0, 12.8, 17.2, 9.2, 0.5, 7.5], + [2024, 30.0, 12.8, 17.2, 9.2, 0.5, 7.5], + [2025, 30.0, 12.8, 17.2, 9.2, 0.5, 7.5], + [2026, 31.4, 12.8, 18.6, 10.6, 0.5, 7.5], + ]; + for (const row of data) insertPfu.run(...row); + }); + seedPfu(); +} + +// ── Migration : categorie_id sur investissements ────────────────────────── +{ + const invColsCat = db.prepare('PRAGMA table_info(investissements)').all().map(c => c.name); + if (!invColsCat.includes('categorie_id')) { + db.exec('ALTER TABLE investissements ADD COLUMN categorie_id INTEGER REFERENCES categories_plateforme(id) ON DELETE SET NULL'); + } +} + +// ── Migration : remboursements — investissement_id nullable + type + bonus ── +// Recrée la table pour lever la contrainte NOT NULL sur investissement_id +// et ajouter les colonnes bonus_plateforme_id, bonus_investisseur_id, type. +{ + const rembColsBonus = db.prepare('PRAGMA table_info(remboursements)').all().map(c => c.name); + if (!rembColsBonus.includes('bonus_plateforme_id')) { + const tempName = '__repair_remboursements'; + + db.exec('PRAGMA foreign_keys = OFF'); + db.exec('DROP VIEW IF EXISTS v_interets_annuels'); + db.exec(`DROP TABLE IF EXISTS "${tempName}"`); + + db.exec(`CREATE TABLE "${tempName}" ( + id INTEGER PRIMARY KEY, + investissement_id INTEGER REFERENCES investissements(id) ON DELETE CASCADE, + bonus_plateforme_id INTEGER REFERENCES plateformes(id) ON DELETE SET NULL, + bonus_investisseur_id INTEGER REFERENCES investisseurs(id) ON DELETE SET NULL, + type TEXT NOT NULL DEFAULT 'normal', + date_remb TEXT NOT NULL, + capital REAL NOT NULL DEFAULT 0, + interets_bruts REAL NOT NULL DEFAULT 0, + prelev_sociaux REAL NOT NULL DEFAULT 0, + prelev_forfaitaire REAL NOT NULL DEFAULT 0, + cashback REAL NOT NULL DEFAULT 0, + interets_nets REAL NOT NULL DEFAULT 0, + net_recu REAL NOT NULL DEFAULT 0, + statut TEXT NOT NULL DEFAULT 'paye' + CHECK(statut IN ('paye','retard','partiel','impaye')), + source TEXT NOT NULL DEFAULT 'manuel', + notes TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + )`); + + // Copier toutes les colonnes existantes (investissement_id était NOT NULL) + const existingCols = rembColsBonus.map(c => `"${c}"`).join(', '); + db.exec(`INSERT INTO "${tempName}" (${existingCols}) SELECT ${existingCols} FROM remboursements`); + + const idxs = db.prepare( + "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='remboursements' AND sql IS NOT NULL" + ).all(); + for (const idx of idxs) db.exec(`DROP INDEX IF EXISTS "${idx.name}"`); + + db.exec('DROP TABLE remboursements'); + db.exec('PRAGMA legacy_alter_table = ON'); + db.exec(`ALTER TABLE "${tempName}" RENAME TO remboursements`); + db.exec('PRAGMA legacy_alter_table = OFF'); + + db.exec('CREATE INDEX IF NOT EXISTS idx_remb_inv ON remboursements(investissement_id)'); + db.exec('CREATE INDEX IF NOT EXISTS idx_remb_date ON remboursements(date_remb)'); + db.exec('CREATE INDEX IF NOT EXISTS idx_remb_statut ON remboursements(statut)'); + + // Recréer la vue (exclut les bonus qui n'ont pas d'investissement_id) + db.exec(`CREATE VIEW IF NOT EXISTS v_interets_annuels AS + SELECT + i.investisseur_id, + substr(r.date_remb,1,4) AS annee, + SUM(r.interets_bruts) AS interets_bruts, + SUM(r.prelev_sociaux) AS prelev_sociaux, + SUM(r.prelev_forfaitaire) AS prelev_forfaitaire, + SUM(r.net_recu) AS net_recu + FROM remboursements r + JOIN investissements i ON i.id = r.investissement_id + WHERE r.statut IN ('paye','partiel') + AND r.type = 'normal' + GROUP BY i.investisseur_id, substr(r.date_remb,1,4)`); + + db.exec('PRAGMA foreign_keys = ON'); + } +} + +// ── Migration : categorie_id sur investissements ────────────────────────── +{ + const invColsCat = db.prepare('PRAGMA table_info(investissements)').all().map(c => c.name); + if (!invColsCat.includes('categorie_id')) { + db.exec('ALTER TABLE investissements ADD COLUMN categorie_id INTEGER REFERENCES categories_plateforme(id) ON DELETE SET NULL'); + } +} + +// ── Migration : remboursements — investissement_id nullable + type + bonus ── +// Recrée la table pour lever la contrainte NOT NULL sur investissement_id +// et ajouter les colonnes bonus_plateforme_id, bonus_investisseur_id, type. +{ + const rembColsBonus = db.prepare('PRAGMA table_info(remboursements)').all().map(c => c.name); + if (!rembColsBonus.includes('bonus_plateforme_id')) { + const tempName = '__repair_remboursements'; + + db.exec('PRAGMA foreign_keys = OFF'); + db.exec('DROP VIEW IF EXISTS v_interets_annuels'); + db.exec(`DROP TABLE IF EXISTS "${tempName}"`); + + db.exec(`CREATE TABLE "${tempName}" ( + id INTEGER PRIMARY KEY, + investissement_id INTEGER REFERENCES investissements(id) ON DELETE CASCADE, + bonus_plateforme_id INTEGER REFERENCES plateformes(id) ON DELETE SET NULL, + bonus_investisseur_id INTEGER REFERENCES investisseurs(id) ON DELETE SET NULL, + type TEXT NOT NULL DEFAULT 'normal', + date_remb TEXT NOT NULL, + capital REAL NOT NULL DEFAULT 0, + interets_bruts REAL NOT NULL DEFAULT 0, + prelev_sociaux REAL NOT NULL DEFAULT 0, + prelev_forfaitaire REAL NOT NULL DEFAULT 0, + cashback REAL NOT NULL DEFAULT 0, + interets_nets REAL NOT NULL DEFAULT 0, + net_recu REAL NOT NULL DEFAULT 0, + statut TEXT NOT NULL DEFAULT 'paye' + CHECK(statut IN ('paye','retard','partiel','impaye')), + source TEXT NOT NULL DEFAULT 'manuel', + notes TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + )`); + + // Copier toutes les colonnes existantes (investissement_id était NOT NULL) + const existingCols = rembColsBonus.map(c => `"${c}"`).join(', '); + db.exec(`INSERT INTO "${tempName}" (${existingCols}) SELECT ${existingCols} FROM remboursements`); + + const idxs = db.prepare( + "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='remboursements' AND sql IS NOT NULL" + ).all(); + for (const idx of idxs) db.exec(`DROP INDEX IF EXISTS "${idx.name}"`); + + db.exec('DROP TABLE remboursements'); + db.exec('PRAGMA legacy_alter_table = ON'); + db.exec(`ALTER TABLE "${tempName}" RENAME TO remboursements`); + db.exec('PRAGMA legacy_alter_table = OFF'); + + db.exec('CREATE INDEX IF NOT EXISTS idx_remb_inv ON remboursements(investissement_id)'); + db.exec('CREATE INDEX IF NOT EXISTS idx_remb_date ON remboursements(date_remb)'); + db.exec('CREATE INDEX IF NOT EXISTS idx_remb_statut ON remboursements(statut)'); + + // Recréer la vue (exclut les bonus qui n'ont pas d'investissement_id) + db.exec(`CREATE VIEW IF NOT EXISTS v_interets_annuels AS + SELECT + i.investisseur_id, + substr(r.date_remb,1,4) AS annee, + SUM(r.interets_bruts) AS interets_bruts, + SUM(r.prelev_sociaux) AS prelev_sociaux, + SUM(r.prelev_forfaitaire) AS prelev_forfaitaire, + SUM(r.net_recu) AS net_recu + FROM remboursements r + JOIN investissements i ON i.id = r.investissement_id + WHERE r.statut IN ('paye','partiel') + AND r.type = 'normal' + GROUP BY i.investisseur_id, substr(r.date_remb,1,4)`); + + db.exec('PRAGMA foreign_keys = ON'); + } +} + +// ── Migration : logo_filename sur plateformes ───────────────────────────── +{ + const platColsLogo = db.prepare('PRAGMA table_info(plateformes)').all().map(c => c.name); + if (!platColsLogo.includes('logo_filename')) { + db.exec('ALTER TABLE plateformes ADD COLUMN logo_filename TEXT'); + } +} + +// ── Migration : icone_filename sur plateformes ─────────────────────────── +{ + const platColsIcone = db.prepare('PRAGMA table_info(plateformes)').all().map(c => c.name); + if (!platColsIcone.includes('icone_filename')) { + db.exec('ALTER TABLE plateformes ADD COLUMN icone_filename TEXT'); + } +} + +// ── Migration : investisseur_id + date_ouverture sur plateformes ────────── +{ + const platCols2 = db.prepare('PRAGMA table_info(plateformes)').all().map(c => c.name); + if (!platCols2.includes('investisseur_id')) { + db.exec('ALTER TABLE plateformes ADD COLUMN investisseur_id INTEGER REFERENCES investisseurs(id) ON DELETE SET NULL'); + // Backfill : associer au compte is_principal de l'utilisateur propriétaire + db.exec(` + UPDATE plateformes SET investisseur_id = ( + SELECT inv.id FROM investisseurs inv + WHERE inv.user_id = plateformes.user_id AND inv.is_principal = 1 + LIMIT 1 + ) + WHERE investisseur_id IS NULL + `); + } + if (!platCols2.includes('date_ouverture')) { + db.exec('ALTER TABLE plateformes ADD COLUMN date_ouverture TEXT'); + } +} + +// ── Migration : valeurs par défaut sur plateformes ──────────────────────── +{ + const platColsDef = db.prepare('PRAGMA table_info(plateformes)').all().map(c => c.name); + if (!platColsDef.includes('type_pret_defaut')) { + db.exec('ALTER TABLE plateformes ADD COLUMN type_pret_defaut TEXT'); + } + if (!platColsDef.includes('duree_defaut')) { + db.exec('ALTER TABLE plateformes ADD COLUMN duree_defaut INTEGER'); + } + if (!platColsDef.includes('taux_defaut')) { + db.exec('ALTER TABLE plateformes ADD COLUMN taux_defaut REAL'); + } + if (!platColsDef.includes('freq_interets_defaut')) { + db.exec("ALTER TABLE plateformes ADD COLUMN freq_interets_defaut TEXT"); + } +} + + + +// ── Migration : table comptes ───────────────────────────────────────────── +{ + const comptesExists = db.prepare( + "SELECT COUNT(*) AS n FROM sqlite_master WHERE type='table' AND name='comptes'" + ).get().n > 0; + if (!comptesExists) { + db.exec(` + CREATE TABLE comptes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + investisseur_id INTEGER REFERENCES investisseurs(id) ON DELETE SET NULL, + nom TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'compte_courant', + banque TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT + ) + `); + db.exec('CREATE INDEX IF NOT EXISTS idx_comptes_user ON comptes(user_id)'); + db.exec('CREATE INDEX IF NOT EXISTS idx_comptes_inv ON comptes(investisseur_id)'); + } +} + +// ── Migration : exoneration_fiscale sur comptes ───────────────────────────── +{ + const cols = db.prepare('PRAGMA table_info(comptes)').all().map(c => c.name); + if (!cols.includes('exoneration_fiscale')) { + db.exec("ALTER TABLE comptes ADD COLUMN exoneration_fiscale TEXT NOT NULL DEFAULT 'aucune'"); + } +} + +// ── Migration : UNIQUE(user_id, nom) → UNIQUE(user_id, nom, investisseur_id) ─ +// Permet d'avoir la même plateforme (même nom) détenue par deux investisseurs +// différents. SQLite ne supporte pas DROP CONSTRAINT → recréation de la table. +{ + const platSql = db.prepare( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='plateformes'" + ).get()?.sql ?? ''; + + // Détecte l'ancienne contrainte UNIQUE(user_id, nom) sans investisseur_id + const needsMigration = /UNIQUE\s*\(\s*user_id\s*,\s*nom\s*\)/.test(platSql) + && !/UNIQUE\s*\(\s*user_id\s*,\s*nom\s*,\s*investisseur_id\s*\)/.test(platSql); + + if (needsMigration) { + const tempName = '__repair_plateformes'; + const platCols = db.prepare('PRAGMA table_info(plateformes)').all(); + const colNames = platCols.map(c => `"${c.name}"`).join(', '); + + const idxs = db.prepare( + "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='plateformes' AND sql IS NOT NULL" + ).all(); + + db.exec('PRAGMA foreign_keys = OFF'); + db.exec(`DROP TABLE IF EXISTS "${tempName}"`); + db.exec(`CREATE TABLE "${tempName}" ( + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + nom TEXT NOT NULL, + url TEXT, + notes TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + domiciliation TEXT NOT NULL DEFAULT 'france', + fiscalite TEXT NOT NULL DEFAULT 'flat_tax', + taux_fiscalite_locale REAL, + methode_remboursement TEXT NOT NULL DEFAULT 'portefeuille', + investisseur_id INTEGER REFERENCES investisseurs(id) ON DELETE SET NULL, + date_ouverture TEXT, + logo_filename TEXT, + UNIQUE(user_id, nom, investisseur_id) + )`); + db.exec(`INSERT INTO "${tempName}" (${colNames}) SELECT ${colNames} FROM plateformes`); + for (const idx of idxs) db.exec(`DROP INDEX IF EXISTS "${idx.name}"`); + db.exec('DROP TABLE plateformes'); + db.exec('PRAGMA legacy_alter_table = ON'); + db.exec(`ALTER TABLE "${tempName}" RENAME TO plateformes`); + db.exec('PRAGMA legacy_alter_table = OFF'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('CREATE INDEX IF NOT EXISTS idx_plateformes_user ON plateformes(user_id)'); + } +} + +// ── Migration : echeance_fin_de_mois sur investissements ───────────────── +// Indicateur "dernier jour du mois" pour les prêts in fine / différés +// dont la date de première échéance tombe après le 27 du mois. +{ + const invColsEfm = db.prepare('PRAGMA table_info(investissements)').all().map(c => c.name); + if (!invColsEfm.includes('echeance_fin_de_mois')) { + db.exec('ALTER TABLE investissements ADD COLUMN echeance_fin_de_mois INTEGER NOT NULL DEFAULT 0'); + } +} + +// ── Migration : table reinvestissements ────────────────────────────────── +db.exec(` + CREATE TABLE IF NOT EXISTS reinvestissements ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + investissement_id INTEGER NOT NULL REFERENCES investissements(id) ON DELETE CASCADE, + montant REAL NOT NULL, + date_reinvestissement TEXT NOT NULL, + note TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) +`); +db.exec('CREATE INDEX IF NOT EXISTS idx_reinv_inv ON reinvestissements(investissement_id, date_reinvestissement)'); + +// ── Migration : table corrections_solde ────────────────────────────────── +// Corrections manuelles de solde porte-monnaie pour plateformes flat_tax. +// Permet de réconcilier les micro-écarts de calcul de taxe (ex. 0,01€) +// en déclarant le solde constaté après une opération dépôt/retrait. +db.exec(` + CREATE TABLE IF NOT EXISTS corrections_solde ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + investisseur_id INTEGER NOT NULL REFERENCES investisseurs(id) ON DELETE CASCADE, + plateforme_id INTEGER NOT NULL REFERENCES plateformes(id) ON DELETE CASCADE, + date TEXT NOT NULL, + montant REAL NOT NULL, + notes TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) +`); +db.exec('CREATE INDEX IF NOT EXISTS idx_corrections_investisseur ON corrections_solde(investisseur_id, date)'); +db.exec('CREATE INDEX IF NOT EXISTS idx_corrections_plateforme ON corrections_solde(plateforme_id, date)'); + +// ── Migration : fiscalité locale sur remboursements ────────────────────── +// Stocke le montant brut avant retenue et le montant de la taxe locale +// (pour les plateformes avec fiscalite = 'avec_fiscalite_locale'). +{ + const rembColsLocal = db.prepare('PRAGMA table_info(remboursements)').all().map(c => c.name); + if (!rembColsLocal.includes('interets_bruts_avant_local')) { + db.exec('ALTER TABLE remboursements ADD COLUMN interets_bruts_avant_local REAL NOT NULL DEFAULT 0'); + } + if (!rembColsLocal.includes('taxe_locale')) { + db.exec('ALTER TABLE remboursements ADD COLUMN taxe_locale REAL NOT NULL DEFAULT 0'); + } +} + +// ── Migration : réinvestissement automatique ───────────────────────────── +// auto_reinvest sur investissements : active le réinvestissement automatique des intérêts. +// source sur reinvestissements : 'manuel' (défaut) ou 'auto'. +{ + const invCols = db.prepare('PRAGMA table_info(investissements)').all().map(c => c.name); + const reinvCols = db.prepare('PRAGMA table_info(reinvestissements)').all().map(c => c.name); + if (!invCols.includes('auto_reinvest')) { + db.exec('ALTER TABLE investissements ADD COLUMN auto_reinvest INTEGER NOT NULL DEFAULT 0'); + } + if (!reinvCols.includes('source')) { + db.exec("ALTER TABLE reinvestissements ADD COLUMN source TEXT NOT NULL DEFAULT 'manuel'"); + } +} + +// ── Migration : fiscalite_override sur investissements ────────────────────── +// Permet d'exonérer un investissement de la flat tax, indépendamment du paramétrage +// de la plateforme (ex : investissement logé en PEA-PME). +// Valeurs : null (suit la plateforme) | 'exonere' (traité comme sans_fiscalite_locale). +{ + const invCols = db.prepare('PRAGMA table_info(investissements)').all().map(c => c.name); + if (!invCols.includes('fiscalite_override')) { + db.exec('ALTER TABLE investissements ADD COLUMN fiscalite_override TEXT'); + } +} + +// ── Migration : nom_compte_courant sur investissements ────────────────────── +// Nom du compte bancaire de l'investisseur quand methode_remboursement = 'compte_courant'. +{ + const invCols = db.prepare('PRAGMA table_info(investissements)').all().map(c => c.name); + if (!invCols.includes('nom_compte_courant')) { + db.exec('ALTER TABLE investissements ADD COLUMN nom_compte_courant TEXT'); + } +} + +// ── Migration : methode_remboursement sur investissements ──────────────────── +// Permet à l'investisseur de préciser sa préférence de remboursement quand la +// plateforme est configurée 'choix_investisseur'. +// Valeurs : 'portefeuille' | 'compte_courant' | null (non renseigné). +{ + const invCols = db.prepare('PRAGMA table_info(investissements)').all().map(c => c.name); + if (!invCols.includes('methode_remboursement')) { + db.exec('ALTER TABLE investissements ADD COLUMN methode_remboursement TEXT'); + } +} + +// ── Migration : pays_exposition sur investissements ────────────────────────── +// Permet d'enregistrer le pays d'exposition du prêt (code ISO 3166-1 alpha-2). +// Défaut : 'FR' (France). +{ + const invCols = db.prepare('PRAGMA table_info(investissements)').all().map(c => c.name); + if (!invCols.includes('pays_exposition')) { + db.exec("ALTER TABLE investissements ADD COLUMN pays_exposition TEXT NOT NULL DEFAULT 'FR'"); + } +} + +export default db; + +// ── Table user_preferences ─────────────────────────────────────────────────── +// Stockage générique des préférences UI par utilisateur. +// Clé/valeur (TEXT) avec upsert. Extensible à toutes les prefs futures. +db.exec(` + CREATE TABLE IF NOT EXISTS user_preferences ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + key TEXT NOT NULL, + value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (user_id, key) + ) +`); + +// ── Tables app_icons + app_icons_history ───────────────────────────────────── +// Bibliothèque d'icônes de l'application, gérée depuis /admin. +// `app_icons` : association nom (slug) ↔ fichier actif +// `app_icons_history` : historique des fichiers remplacés +db.exec(` + CREATE TABLE IF NOT EXISTS app_icons ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + filename TEXT NOT NULL, + description TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ) +`); + +db.exec(` + CREATE TABLE IF NOT EXISTS app_icons_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + icon_id INTEGER NOT NULL REFERENCES app_icons(id) ON DELETE CASCADE, + filename TEXT NOT NULL, + replaced_at TEXT NOT NULL DEFAULT (datetime('now')) + ) +`); + +// ── Seed initial depuis design/Icones/svg ──────────────────────────────────── +{ + const count = db.prepare('SELECT COUNT(*) AS n FROM app_icons').get().n; + if (count === 0) { + const seeds = [ + { name: 'balance', filename: 'icon_balance_seed.svg', description: 'Balance / équilibre' }, + { name: 'capital', filename: 'icon_capital_seed.svg', description: 'Capital investi' }, + { name: 'cashback', filename: 'icon_cashback_seed.svg', description: 'Cashback / bonus' }, + { name: 'depot', filename: 'icon_depot_seed.svg', description: 'Dépôt de fonds' }, + { name: 'interets', filename: 'icon_interets_seed.svg', description: 'Intérêts perçus' }, + { name: 'investissement', filename: 'icon_investissement_seed.svg', description: 'Investissement' }, + { name: 'plateforme', filename: 'icon_plateforme_seed.svg', description: 'Plateforme de prêt' }, + { name: 'remboursement', filename: 'icon_remboursement_seed.svg', description: 'Remboursement' }, + { name: 'retrait', filename: 'icon_retrait_seed.svg', description: 'Retrait de fonds' }, + { name: 'porte-monnaie', filename: 'icon_porte-monaie_seed.svg', description: 'Porte-monnaie' }, + ]; + const ins = db.prepare( + 'INSERT OR IGNORE INTO app_icons (name, filename, description) VALUES (?,?,?)' + ); + for (const s of seeds) ins.run(s.name, s.filename, s.description); + } +} + +// ── Migration : compte_id sur investissements ──────────────────────────────── +// Lie un investissement au compte bancaire du détenteur (FK → comptes.id). +{ + const cols = db.prepare('PRAGMA table_info(investissements)').all().map(c => c.name); + if (!cols.includes('compte_id')) { + db.exec('ALTER TABLE investissements ADD COLUMN compte_id INTEGER REFERENCES comptes(id) ON DELETE SET NULL'); + } +} + +// ── Migration : compte_id sur remboursements ───────────────────────────────── +// Lie un remboursement au compte bancaire récepteur (FK → comptes.id). +{ + const cols = db.prepare('PRAGMA table_info(remboursements)').all().map(c => c.name); + if (!cols.includes('compte_id')) { + db.exec('ALTER TABLE remboursements ADD COLUMN compte_id INTEGER REFERENCES comptes(id) ON DELETE SET NULL'); + } +} + +// ── Migration : détail des taux 2778-SD sur taux_pfu ───────────────────────── +// Décompose prelev_sociaux en ses 3 composantes pour le calcul de la déclaration +// mensuelle 2778-SD (taux identiques au PFU global mais ventilés par case). +// Taux 2021-2025 : CSG 9,2 % | CRDS 0,5 % | Solidarité 7,5 % → total 17,2 % +// Taux 2026+ : CSG 10,6 %| CRDS 0,5 % | Solidarité 7,5 % → total 18,6 % +{ + const pfuCols = db.prepare('PRAGMA table_info(taux_pfu)').all().map(c => c.name); + if (!pfuCols.includes('csg')) { + db.exec('ALTER TABLE taux_pfu ADD COLUMN csg REAL NOT NULL DEFAULT 9.2'); + // Mise à jour des années 2026+ où la CSG passe à 10,6 % + db.exec('UPDATE taux_pfu SET csg = 10.6 WHERE annee >= 2026'); + } + if (!pfuCols.includes('crds')) { + db.exec('ALTER TABLE taux_pfu ADD COLUMN crds REAL NOT NULL DEFAULT 0.5'); + } + if (!pfuCols.includes('solidarite')) { + db.exec('ALTER TABLE taux_pfu ADD COLUMN solidarite REAL NOT NULL DEFAULT 7.5'); + } +} + + +// ── Table taux_credit_impot — Référentiel 2047 crédit d'impôt par pays ─────── +// Stocke les taux de crédit d'impôt applicables sur dividendes et intérêts +// selon les conventions fiscales, pour la déclaration 2047. +// Alimenté depuis les pages 5-6 de la notice DGFiP 2047 (édition 2024). +{ + const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(r => r.name); + if (!tables.includes('taux_credit_impot')) { + db.exec(` + CREATE TABLE taux_credit_impot ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + nom_pays TEXT NOT NULL, + code_pays TEXT, + div_taux REAL, + div_taux_alt REAL, + div_taux_alt_label TEXT, + div_exclusif_residence INTEGER NOT NULL DEFAULT 0, + int_taux REAL, + int_taux_alt REAL, + int_taux_alt_label TEXT, + int_exclusif_residence INTEGER NOT NULL DEFAULT 0, + notice TEXT, + statut_convention TEXT NOT NULL DEFAULT 'active', + date_suspension TEXT, + ref_boi TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + `); + + // Seed depuis notice DGFiP 2047 (pages 5-6) + // Colonnes : nom_pays, code_pays, div_taux, div_taux_alt, div_taux_alt_label, + // div_excl_res, int_taux, int_taux_alt, int_taux_alt_label, int_excl_res, + // statut_convention, date_suspension, ref_boi, notice + const seed = [ + ["Afrique du Sud","ZA",17.6,null,null,0,null,null,null,1,"active",null,null,null], + ["Albanie","AL",17.6,null,null,0,11.1,null,null,0,"active",null,null,null], + ["Algérie","DZ",17.6,null,null,0,13.6,null,null,0,"active",null,null,null], + ["Allemagne","DE",17.6,null,null,0,null,null,null,1,"active",null,null,null], + ["Andorre","AD",17.6,null,null,0,5.3,null,null,0,"active",null,null,null], + ["Arabie Saoudite","SA",null,null,null,1,null,null,null,1,"active",null,null,null], + ["Argentine","AR",17.6,null,null,0,25.0,null,null,0,"active",null,null,null], + ["Arménie","AM",17.6,null,null,0,11.1,null,null,0,"active",null,null,null], + ["Australie","AU",17.6,null,null,0,11.1,null,null,0,"active",null,null,null], + ["Autriche","AT",17.6,null,null,0,null,null,null,1,"active",null,null,null], + ["Azerbaïdjan","AZ",11.1,null,null,0,11.1,null,null,0,"active",null,null,null], + ["Bahreïn","BH",null,null,null,1,null,null,null,1,"active",null,null,null], + ["Bangladesh","BD",25.0,null,null,0,25.0,null,null,0,"active",null,null,"Dividendes et intérêts : le crédit d'impôt est égal à l'impôt prélevé au Bangladesh dans les limites prévues par la convention, augmenté de 10 % du montant brut du revenu, sans que le total puisse excéder 20 % du montant brut de ce revenu. En l'absence de retenue à la source au Bangladesh, le taux du crédit d'impôt est de 10 %."], + ["Belgique","BE",17.6,null,null,0,17.6,null,null,0,"active",null,null,null], + ["Bénin","BJ",25.0,null,null,0,null,null,null,0,"active",null,null,"Intérêts : le crédit d'impôt est égal à l'impôt prélevé au Bénin. Dividendes : la déduction correspondant à l'impôt effectivement payé au Bénin est égale à 25 % du montant brut des revenus."], + ["Biélorussie","BY",17.6,null,null,0,11.1,null,null,0,"suspendue","2024-06-01",null,"La convention fiscale applicable entre la France et la Biélorussie est suspendue à compter du 1er juin 2024. Convention ex-URSS."], + ["Bolivie","BO",17.6,null,null,0,17.6,null,null,0,"active",null,null,null], + ["Bosnie-Herzégovine","BA",17.6,null,null,0,null,null,null,1,"active",null,null,"Convention fiscale conclue entre la France et la République socialiste fédérative de Yougoslavie."], + ["Botswana","BW",13.6,null,null,0,11.1,null,null,0,"active",null,null,null], + ["Brésil","BR",20.0,null,null,0,20.0,null,null,0,"active",null,null,"Dividendes et intérêts : crédit d'impôt forfaitaire égal à 20 % du montant brut de ceux-ci lorsque les revenus ont été effectivement imposés au Brésil."], + ["Bulgarie","BG",17.6,null,null,0,null,null,null,1,"active",null,null,null], + ["Burkina Faso","BF",null,null,null,0,null,null,null,0,"caduque","2023-11-08","BOI-INT-CVB-BFA","La convention fiscale conclue entre la France et le Burkina Faso a cessé de produire ses effets à compter du 8 novembre 2023."], + ["Cameroun","CM",17.6,null,null,0,17.6,null,null,0,"active",null,null,null], + ["Canada","CA",17.6,null,null,0,11.1,null,null,0,"active",null,null,"Québec compris."], + ["Centrafricaine (Rép.)","CF",33.3,null,null,0,13.6,null,null,0,"active",null,null,null], + ["Chili","CL",null,null,null,0,10.0,null,null,0,"active",null,null,"Dividendes : crédit d'impôt égal à la plus petite des sommes suivantes : montant de l'impôt additionnel payé au Chili après déduction de l'impôt de première catégorie, ou 15 % du montant brut des dividendes. Intérêts : crédit d'impôt de 10 % en application de la clause de la nation la plus favorisée."], + ["Chine","CN",11.1,null,null,0,11.1,null,null,0,"active",null,null,null], + ["Chypre","CY",17.6,null,null,0,11.1,null,null,0,"active",null,null,null], + ["Colombie","CO",17.6,null,null,0,11.1,null,null,0,"active",null,null,null], + ["Congo","CG",25.0,null,null,0,null,null,null,1,"active",null,null,null], + ["Corée du Sud","KR",17.6,null,null,0,11.1,null,null,0,"active",null,null,null], + ["Côte d'Ivoire","CI",17.6,22.0,"Société exonérée ou à taux réduit IS",0,17.6,null,null,0,"active",null,null,"Dividendes : crédit d'impôt plafonné à 18 % du montant brut lorsqu'ils sont payés par une société exonérée de l'IS ou acquittant cet impôt à taux réduit, et à 15 % dans les autres cas."], + ["Croatie","HR",17.6,null,null,0,null,null,null,1,"active",null,null,null], + ["Danemark","DK",17.6,null,null,0,null,null,null,1,"active",null,null,null], + ["Égypte","EG",null,null,null,1,17.6,null,null,0,"active",null,null,null], + ["Émirats arabes unis","AE",null,null,null,1,null,null,null,1,"active",null,null,null], + ["Équateur","EC",17.6,null,null,0,17.6,11.1,"Conditions particulières conv.",0,"active",null,null,null], + ["Espagne","ES",17.6,null,null,0,11.1,null,null,0,"active",null,null,null], + ["Estonie","EE",17.6,null,null,0,11.1,null,null,0,"active",null,null,null], + ["États-Unis","US",17.6,null,null,0,17.6,null,null,0,"active",null,null,"Dividendes : crédit d'impôt égal à l'impôt américain, dans la limite de 15 % du montant brut. Certains dividendes de résidents de France possédant la citoyenneté américaine ouvrent droit à un crédit d'impôt égal au montant de l'impôt français (art. 24 §1-b-i de la convention du 31 août 1994). Intérêts : crédit d'impôt égal à l'impôt américain, dans la limite de 15 % du montant brut. Les intérêts exonérés aux États-Unis n'ouvrent droit à aucun crédit d'impôt en France."], + ["Éthiopie","ET",5.3,null,null,0,5.3,null,null,0,"active",null,null,null], + ["Finlande","FI",null,null,null,1,11.1,null,null,0,"active",null,null,null], + ["Gabon","GA",17.6,null,null,0,11.1,null,null,0,"active",null,null,null], + ["Géorgie","GE",11.1,null,null,0,null,null,null,1,"active",null,null,null], + ["Ghana","GH",17.6,null,null,0,14.3,null,null,0,"active",null,null,null], + ["Grèce","GR",17.6,null,null,0,5.3,null,null,0,"active",null,null,null], + ["Guinée","GN",17.6,null,null,0,11.1,null,null,0,"active",null,null,null], + ["Hong-Kong","HK",11.1,null,null,0,11.1,null,null,0,"active",null,null,null], + ["Hongrie","HU",17.6,null,null,0,null,null,null,1,"active",null,null,null], + ["Inde","IN",11.1,null,null,0,10.0,null,null,0,"active",null,null,"Intérêts : crédit d'impôt de 10 % en application de la clause de la nation la plus favorisée. Les intérêts de source indienne n'ayant pas supporté l'impôt en Inde ouvrent droit à un crédit forfaitaire correspondant à l'impôt qui aurait été perçu, plafonné à 10 %."], + ["Indonésie","ID",17.6,null,null,0,17.6,null,null,0,"active",null,null,"Dividendes et intérêts : crédit d'impôt égal à 10 % du montant brut lorsque l'impôt indonésien n'est pas perçu ou lorsqu'il est perçu à un taux inférieur à 10 % du fait de mesures spéciales incitatives."], + ["Iran","IR",25.0,null,null,0,17.6,null,null,0,"active",null,null,null], + ["Irlande","IE",null,null,null,1,null,null,null,1,"active",null,null,null], + ["Islande","IS",17.6,null,null,0,null,null,null,1,"active",null,null,null], + ["Israël","IL",17.6,null,null,0,11.1,null,null,0,"active",null,null,null], + ["Italie","IT",17.6,null,null,0,11.1,null,null,0,"active",null,null,null], + ["Jamaïque","JM",17.6,null,null,0,11.1,null,null,0,"active",null,null,null], + ["Japon","JP",11.1,null,null,0,11.1,null,null,0,"active",null,null,null], + ["Jordanie","JO",17.6,null,null,0,17.6,null,null,0,"active",null,null,null], + ["Kazakhstan","KZ",11.1,null,null,0,11.1,null,null,0,"active",null,null,null], + ["Kenya","KE",11.1,null,null,0,13.6,null,null,0,"active",null,null,null], + ["Kirghizistan","KG",17.6,null,null,0,11.1,null,null,0,"active",null,null,"Convention fiscale conclue entre la France et l'ex-URSS."], + ["Kosovo","XK",17.6,null,null,0,null,null,null,1,"active",null,null,"Convention fiscale conclue entre la France et la République socialiste fédérative de Yougoslavie."], + ["Koweït","KW",null,null,null,1,null,null,null,1,"active",null,null,null], + ["Lettonie","LV",17.6,null,null,0,11.1,null,null,0,"active",null,null,null], + ["Liban","LB",null,null,null,1,null,null,null,1,"active",null,null,null], + ["Libye","LY",11.1,null,null,0,null,null,null,1,"active",null,null,null], + ["Lituanie","LT",17.6,null,null,0,11.1,null,null,0,"active",null,null,null], + ["Luxembourg","LU",17.6,null,null,0,null,null,null,1,"active",null,null,"Dividendes : le crédit d'impôt ne peut excéder 25 % du montant brut des dividendes."], + ["Macédoine","MK",17.6,null,null,0,null,null,null,1,"active",null,null,null], + ["Madagascar","MG",33.3,null,null,0,17.6,null,null,0,"active",null,null,null], + ["Malaisie","MY",null,null,null,1,17.6,null,null,0,"active",null,null,"Intérêts : le crédit d'impôt est égal à l'impôt prélevé en Malaisie."], + ["Mali","ML",null,null,null,0,null,null,null,0,"caduque","2024-03-05","BOI-INT-CVB-MLI","La convention fiscale conclue entre la France et le Mali a cessé de produire ses effets à compter du 5 mars 2024."], + ["Malte","MT",17.6,null,null,0,5.3,null,null,0,"active",null,null,null], + ["Maroc","MA",25.0,null,null,0,17.6,11.1,"Emprunts organismes de développement",0,"active",null,null,"Dividendes ayant supporté l'impôt au Maroc : crédit d'impôt égal à 25 % du montant brut. Intérêts : crédit forfaitaire de 10 % lorsqu'ils proviennent d'emprunts émis par certains organismes spécialisés pour le développement économique du Maroc (plafonné à l'impôt français). Dans les autres cas, CI égal à l'impôt effectivement supporté."], + ["Maurice","MU",33.3,null,null,0,null,null,null,0,"active",null,null,"Dividendes : le crédit d'impôt ne peut excéder 25 % du montant brut des dividendes."], + ["Mauritanie","MR",33.3,null,null,0,16.0,12.0,"Obligations négociables",0,"active",null,null,"Dividendes : crédit d'impôt égal à 25 % du montant brut lorsque effectivement imposés en Mauritanie. Intérêts : crédit d'impôt de 16 % du montant brut (prêts, dépôts, bons de caisse non négociables) ; 12 % pour les obligations négociables."], + ["Mexique","MX",17.6,null,null,0,11.1,5.3,"Conditions particulières conv.",0,"active",null,null,null], + ["Moldavie","MD",11.1,null,null,0,5.3,null,null,0,"active",null,null,null], + ["Mongolie","MN",17.6,null,null,0,11.1,null,null,0,"active",null,null,"Dividendes et intérêts : l'art. 23 §I d) iii prévoit une modalité particulière de calcul du crédit d'impôt lorsque le revenu a bénéficié d'une réduction ou d'une suppression de l'impôt à la source qui n'est plus applicable."], + ["Monténégro","ME",17.6,null,null,0,null,null,null,1,"active",null,null,"Convention fiscale conclue entre la France et la République socialiste fédérative de Yougoslavie."], + ["Namibie","NA",17.6,null,null,0,11.1,null,null,0,"active",null,null,null], + ["Niger","NE",null,null,null,0,null,null,null,0,"suspendue","2024-06-05","BOI-INT-CVB-NER","La convention fiscale conclue entre la France et le Niger est suspendue à compter du 5 juin 2024. Intérêts : CI égal à l'impôt payé au Niger ; intérêts de prêts/dépôts non négociables ouvrent droit à CI de 16 % du montant brut."], + ["Nigéria","NG",17.6,null,null,0,14.3,null,null,0,"active",null,null,"Dividendes et intérêts : crédit d'impôt de 15 % (div.) ou 12,5 % (int.) lorsqu'ils sont exonérés ou soumis à taux réduit en vertu de la législation nigériane sur le développement économique."], + ["Norvège","NO",17.6,null,null,0,null,null,null,1,"active",null,null,null], + ["Nouvelle-Calédonie","NC",17.6,null,null,0,null,null,null,1,"active",null,null,null], + ["Nouvelle-Zélande","NZ",17.6,null,null,0,11.1,null,null,0,"active",null,null,null], + ["Oman","OM",null,null,null,1,null,null,null,1,"active",null,null,null], + ["Ouzbékistan","UZ",8.7,null,null,0,5.3,null,null,0,"active",null,null,null], + ["Pakistan","PK",17.6,null,null,0,11.1,null,null,0,"active",null,null,null], + ["Panama","PA",17.6,null,null,0,5.3,null,null,0,"active",null,null,null], + ["Pays-Bas","NL",17.6,null,null,0,11.1,null,null,0,"active",null,null,null], + ["Philippines","PH",17.6,null,null,0,17.6,null,null,0,"active",null,null,null], + ["Pologne","PL",17.6,null,null,0,null,null,null,1,"active",null,null,null], + ["Polynésie française","PF",null,null,null,1,null,null,null,1,"active",null,null,"Les dividendes et intérêts de source polynésienne n'ouvrent pas droit à crédit d'impôt."], + ["Portugal","PT",17.6,null,null,0,10.0,12.0,"Autres emprunts (hors obligations négociables)",0,"active",null,null,"Intérêts ayant donné lieu à imposition au Portugal : 10 % du montant brut pour les obligations et titres d'emprunts négociables ; 12 % du montant brut pour tous autres emprunts."], + ["Qatar","QA",null,null,null,1,null,null,null,1,"active",null,null,null], + ["République tchèque","CZ",11.1,null,null,0,null,null,null,1,"active",null,null,null], + ["Roumanie","RO",11.1,null,null,0,11.1,null,null,0,"active",null,null,null], + ["Royaume-Uni","GB",17.6,null,null,0,null,null,null,1,"active",null,null,null], + ["Russie","RU",null,null,null,0,null,null,null,0,"suspendue","2023-08-08","BOI-INT-CVB-RUS","La convention fiscale conclue entre la France et la Russie est partiellement suspendue à compter du 8 août 2023."], + ["Saint-Barthélemy","BL",null,null,null,0,null,null,null,0,"active",null,null,"En l'absence de convention fiscale, l'art. L.O. 6214-4 du CGCT prévoit l'octroi d'un crédit d'impôt égal à l'impôt payé à Saint-Barthélemy. Cette collectivité n'imposant que les plus-values immobilières, aucun crédit d'impôt n'est accordé pour les autres catégories de revenu."], + ["Saint-Martin","MF",17.6,null,null,0,11.1,null,null,0,"active",null,null,null], + ["Saint-Pierre-et-Miquelon","PM",17.6,null,null,0,null,null,null,1,"active",null,null,null], + ["Sénégal","SN",17.6,null,null,0,17.6,null,null,0,"active",null,null,null], + ["Serbie","RS",17.6,null,null,0,null,null,null,1,"active",null,null,"Convention fiscale conclue entre la France et la République socialiste fédérative de Yougoslavie."], + ["Singapour","SG",17.6,null,null,0,11.1,null,null,0,"active",null,null,null], + ["Slovaquie","SK",11.1,null,null,0,null,null,null,1,"active",null,null,null], + ["Slovénie","SI",17.6,null,null,0,5.3,null,null,0,"active",null,null,null], + ["Sri Lanka","LK",25.0,null,null,0,15.0,null,null,0,"active",null,null,"Dividendes : crédit d'impôt égal à 25 % du montant brut. Intérêts : crédit d'impôt égal à 15 % du montant brut des intérêts de source sri-lankaise ayant supporté un impôt inférieur."], + ["Suède","SE",17.6,null,null,0,null,null,null,1,"active",null,null,null], + ["Suisse","CH",17.6,null,null,0,null,null,null,1,"active",null,null,null], + ["Syrie","SY",17.6,null,null,0,11.1,null,null,0,"active",null,null,null], + ["Taïwan","TW",11.1,null,null,0,11.1,null,null,0,"active",null,null,null], + ["Thaïlande","TH",25.0,null,null,0,null,null,null,1,"active",null,null,"Dividendes : le crédit d'impôt ne peut excéder 25 % du montant brut des dividendes."], + ["Togo","TG",33.3,null,null,0,13.6,null,null,0,"active",null,null,null], + ["Trinité-et-Tobago","TT",17.6,null,null,0,11.1,null,null,0,"active",null,null,"Les intérêts et dividendes visés à l'art. 24-2-c ouvrent droit à un CI correspondant à l'impôt qui aurait été perçu par Trinité-et-Tobago en l'absence des dispositions particulières, plafonné au montant de la retenue à la source prévue aux articles 10 et 11 de la convention."], + ["Tunisie","TN",null,null,null,0,13.6,null,null,0,"active",null,null,"Dividendes : CI calculé par la formule (100 – (25 + t)) / 2, où t est le taux de la retenue à la source tunisienne. Aucun CI n'est accordé si la Tunisie n'impose pas ces revenus."], + ["Turkménistan","TM",17.6,null,null,0,11.1,null,null,0,"active",null,null,"Convention fiscale conclue entre la France et l'ex-URSS."], + ["Turquie","TR",25.0,null,null,0,17.6,null,null,0,"active",null,null,"Dividendes et intérêts : lorsque ces revenus bénéficient d'une exonération ou d'une réduction d'impôt en vertu de mesures sur le développement économique, le CI est égal à l'impôt qui aurait dû être payé en l'absence de ces mesures (max 20 % du montant brut des div., max 15 % pour les int.)."], + ["Ukraine","UA",17.6,null,null,0,11.1,null,null,0,"active",null,null,null], + ["Venezuela","VE",5.3,null,null,0,5.3,null,null,0,"active",null,null,null], + ["Viêt Nam","VN",11.1,null,null,0,null,null,null,1,"active",null,null,null], + ["Zimbabwe","ZW",25.0,null,null,0,11.1,null,null,0,"active",null,null,null] + ]; + + const ins = db.prepare( + 'INSERT INTO taux_credit_impot' + + ' (nom_pays, code_pays,' + + ' div_taux, div_taux_alt, div_taux_alt_label, div_exclusif_residence,' + + ' int_taux, int_taux_alt, int_taux_alt_label, int_exclusif_residence,' + + ' statut_convention, date_suspension, ref_boi, notice)' + + ' VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)' + ); + const txn = db.transaction((rows) => { for (const r of rows) ins.run(...r); }); + txn(seed); + } +} + +// ── Migration : référentiel commun de plateformes (admin) ──────────────────── +// plateformes_referentiel : données partagées gérées par l'administrateur. +// referentiel_categories : junction référentiel ↔ catégories. +// referentiel_notation : critères de notation attachés au référentiel. +// plateformes.referentiel_id : lien vers l'entrée référentiel (nullable). +// plateformes.overridden_fields : JSON array des champs modifiés par l'user. +{ + db.exec(` + CREATE TABLE IF NOT EXISTS plateformes_referentiel ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + nom TEXT NOT NULL UNIQUE, + url TEXT, + domiciliation TEXT NOT NULL DEFAULT 'france', + fiscalite TEXT NOT NULL DEFAULT 'flat_tax', + taux_fiscalite_locale REAL, + type_produit_fiscal TEXT NOT NULL DEFAULT '2TT', + logo_filename TEXT, + description TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + `); + db.exec('CREATE INDEX IF NOT EXISTS idx_ref_nom ON plateformes_referentiel(nom)'); + + // Si la table existe avec l'ancien schéma FK (categorie_id), on la recrée + { + const refCatSql = db.prepare( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='referentiel_categories'" + ).get()?.sql ?? ''; + if (refCatSql && refCatSql.includes('categorie_id')) { + db.exec('DROP TABLE referentiel_categories'); + } + } + db.exec(` + CREATE TABLE IF NOT EXISTS referentiel_categories ( + referentiel_id INTEGER NOT NULL REFERENCES plateformes_referentiel(id) ON DELETE CASCADE, + categorie_nom TEXT NOT NULL, + PRIMARY KEY (referentiel_id, categorie_nom) + ) + `); + + db.exec(` + CREATE TABLE IF NOT EXISTS referentiel_notation ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + referentiel_id INTEGER NOT NULL REFERENCES plateformes_referentiel(id) ON DELETE CASCADE, + nom TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'etoiles', + valeurs TEXT, + min_val REAL, + max_val REAL, + description TEXT, + ordre INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + `); + db.exec('CREATE INDEX IF NOT EXISTS idx_ref_notation ON referentiel_notation(referentiel_id, ordre)'); + + // Colonnes d'héritage sur plateformes + const platColsRef = db.prepare('PRAGMA table_info(plateformes)').all().map(c => c.name); + if (!platColsRef.includes('referentiel_id')) { + db.exec('ALTER TABLE plateformes ADD COLUMN referentiel_id INTEGER REFERENCES plateformes_referentiel(id) ON DELETE SET NULL'); + } + if (!platColsRef.includes('overridden_fields')) { + db.exec("ALTER TABLE plateformes ADD COLUMN overridden_fields TEXT NOT NULL DEFAULT '[]'"); + } +} + +// ── Migration : icone_filename sur plateformes_referentiel ──────────────── +{ + const refCols = db.prepare('PRAGMA table_info(plateformes_referentiel)').all().map(c => c.name); + if (!refCols.includes('icone_filename')) { + db.exec('ALTER TABLE plateformes_referentiel ADD COLUMN icone_filename TEXT'); + } +} + + +// ── Migration : champs remboursement sur plateformes_referentiel ───────────── +{ + const refCols = db.prepare('PRAGMA table_info(plateformes_referentiel)').all().map(c => c.name); + if (!refCols.includes('methode_remboursement')) + db.exec("ALTER TABLE plateformes_referentiel ADD COLUMN methode_remboursement TEXT NOT NULL DEFAULT 'portefeuille'"); + if (!refCols.includes('type_pret_defaut')) + db.exec('ALTER TABLE plateformes_referentiel ADD COLUMN type_pret_defaut TEXT'); + if (!refCols.includes('freq_interets_defaut')) + db.exec('ALTER TABLE plateformes_referentiel ADD COLUMN freq_interets_defaut TEXT'); + + // Remontée : récupère les valeurs depuis les plateformes liées (première valeur non nulle) + db.exec(` + UPDATE plateformes_referentiel + SET + methode_remboursement = COALESCE( + methode_remboursement, + (SELECT p.methode_remboursement FROM plateformes p + WHERE p.referentiel_id = plateformes_referentiel.id + AND p.methode_remboursement IS NOT NULL LIMIT 1) + ), + type_pret_defaut = COALESCE( + type_pret_defaut, + (SELECT p.type_pret_defaut FROM plateformes p + WHERE p.referentiel_id = plateformes_referentiel.id + AND p.type_pret_defaut IS NOT NULL LIMIT 1) + ), + freq_interets_defaut = COALESCE( + freq_interets_defaut, + (SELECT p.freq_interets_defaut FROM plateformes p + WHERE p.referentiel_id = plateformes_referentiel.id + AND p.freq_interets_defaut IS NOT NULL LIMIT 1) + ) + `); +} + +// ── Migration : champs profil sur plateformes_referentiel ──────────────── +{ + const refCols = db.prepare('PRAGMA table_info(plateformes_referentiel)').all().map(c => c.name); + const addCol = (col, def) => { + if (!refCols.includes(col)) db.exec(`ALTER TABLE plateformes_referentiel ADD COLUMN ${col} ${def}`); + }; + + // Identité + addCol('annee_creation', 'INTEGER'); + addCol('type_investissement', 'TEXT'); // 'p2p' | 'dette' | 'equity' | 'mixte' + addCol('secteur', 'TEXT'); // 'immobilier' | 'pme' | 'startups' | 'energie' | ... + addCol('investisseurs_types', 'TEXT'); // 'particulier' | 'entreprise' | 'les_deux' + + // Régulation + addCol('regulateur', 'TEXT'); + addCol('numero_licence', 'TEXT'); + addCol('is_regule', 'INTEGER NOT NULL DEFAULT 0'); + + // Localisation + addCol('pays_inscription', 'TEXT'); + addCol('pays_siege', 'TEXT'); + addCol('pays_operation', 'TEXT'); // JSON array ex: '["France","Allemagne"]' + + // Stats / conditions + addCol('investissement_minimum', 'REAL'); + addCol('rendement_annonce', 'REAL'); + addCol('nb_investisseurs', 'INTEGER'); + addCol('volume_total_finance', 'REAL'); + addCol('duree_moyenne_pret', 'REAL'); // en mois + + // Features booléennes + addCol('garantie_rachat', 'INTEGER NOT NULL DEFAULT 0'); + addCol('statistiques_publiques', 'INTEGER NOT NULL DEFAULT 0'); + addCol('bonus_inscription', 'INTEGER NOT NULL DEFAULT 0'); + addCol('marche_secondaire', 'INTEGER NOT NULL DEFAULT 0'); + addCol('investissement_auto', 'INTEGER NOT NULL DEFAULT 0'); + + // Liens complémentaires + addCol('url_trustpilot', 'TEXT'); + addCol('url_linkedin', 'TEXT'); +} + +// ── Migration : categories_inv + secteurs_inv (listes admin globales) ────── +{ + // Labels lisibles pour la migration des données existantes + const TYPES_INV_LABELS = { + p2p: 'Prêt P2P', + dette: 'Dette', + equity: 'Capitaux propres', + tokenise: 'Tokenisé', + achat_louer: 'Achat à louer', + mini_obligations: 'Mini-obligations', + mixte: 'Mixte', + }; + const SECTEURS_LABELS = { + immobilier: 'Immobilier', + pme: 'PME', + startups: 'Startups', + litige: 'Litige', + energie: 'Énergie verte', + sante_science: 'Santé & Science', + logistique: 'Logistique', + personnel: 'Prêts personnels', + art: 'Art', + autre: 'Autre', + }; + + // 1. Créer les tables globales + db.exec(` + CREATE TABLE IF NOT EXISTS categories_inv ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + nom TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS referentiel_categories_inv ( + referentiel_id INTEGER NOT NULL REFERENCES plateformes_referentiel(id) ON DELETE CASCADE, + categorie_id INTEGER NOT NULL REFERENCES categories_inv(id) ON DELETE CASCADE, + PRIMARY KEY (referentiel_id, categorie_id) + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS secteurs_inv ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + nom TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS referentiel_secteurs_inv ( + referentiel_id INTEGER NOT NULL REFERENCES plateformes_referentiel(id) ON DELETE CASCADE, + secteur_id INTEGER NOT NULL REFERENCES secteurs_inv(id) ON DELETE CASCADE, + PRIMARY KEY (referentiel_id, secteur_id) + ) + `); + + // 2. Seeder categories_inv si vide + const catCount = db.prepare('SELECT COUNT(*) AS n FROM categories_inv').get().n; + if (catCount === 0) { + const insC = db.prepare('INSERT OR IGNORE INTO categories_inv (nom) VALUES (?)'); + db.transaction(() => { + // Valeurs par défaut depuis TYPES_INV + for (const nom of Object.values(TYPES_INV_LABELS)) insC.run(nom); + // Valeurs existantes depuis referentiel_categories (chaînes libres) + const existing = db.prepare('SELECT DISTINCT categorie_nom FROM referentiel_categories').all(); + for (const { categorie_nom } of existing) insC.run(categorie_nom); + // Valeurs existantes depuis type_investissement (slugs → labels) + const typesExist = db.prepare('SELECT DISTINCT type_investissement FROM plateformes_referentiel WHERE type_investissement IS NOT NULL').all(); + for (const { type_investissement: t } of typesExist) { + const label = TYPES_INV_LABELS[t] || t; + insC.run(label); + } + })(); + } + + // 3. Seeder secteurs_inv si vide + const sectCount = db.prepare('SELECT COUNT(*) AS n FROM secteurs_inv').get().n; + if (sectCount === 0) { + const insS = db.prepare('INSERT OR IGNORE INTO secteurs_inv (nom) VALUES (?)'); + db.transaction(() => { + for (const nom of Object.values(SECTEURS_LABELS)) insS.run(nom); + // Valeurs existantes depuis le champ secteur + const sectsExist = db.prepare('SELECT DISTINCT secteur FROM plateformes_referentiel WHERE secteur IS NOT NULL').all(); + for (const { secteur: s } of sectsExist) { + const label = SECTEURS_LABELS[s] || s; + insS.run(label); + } + })(); + } + + // 4. Migrer les données existantes → referentiel_categories_inv + const alreadyMigrated = db.prepare('SELECT COUNT(*) AS n FROM referentiel_categories_inv').get().n; + if (alreadyMigrated === 0) { + const getCatId = db.prepare('SELECT id FROM categories_inv WHERE nom = ?'); + const insLink = db.prepare('INSERT OR IGNORE INTO referentiel_categories_inv (referentiel_id, categorie_id) VALUES (?,?)'); + const refs = db.prepare('SELECT id, type_investissement FROM plateformes_referentiel').all(); + const oldCats = db.prepare('SELECT referentiel_id, categorie_nom FROM referentiel_categories').all(); + + db.transaction(() => { + for (const ref of refs) { + // Depuis type_investissement + if (ref.type_investissement) { + const label = TYPES_INV_LABELS[ref.type_investissement] || ref.type_investissement; + const cat = getCatId.get(label); + if (cat) insLink.run(ref.id, cat.id); + } + } + // Depuis referentiel_categories (chaînes libres) + for (const { referentiel_id, categorie_nom } of oldCats) { + const cat = getCatId.get(categorie_nom); + if (cat) insLink.run(referentiel_id, cat.id); + } + })(); + } + + // 5. Migrer les données existantes → referentiel_secteurs_inv + const alreadySectMigrated = db.prepare('SELECT COUNT(*) AS n FROM referentiel_secteurs_inv').get().n; + if (alreadySectMigrated === 0) { + const getSectId = db.prepare('SELECT id FROM secteurs_inv WHERE nom = ?'); + const insSectLink = db.prepare('INSERT OR IGNORE INTO referentiel_secteurs_inv (referentiel_id, secteur_id) VALUES (?,?)'); + const refs = db.prepare('SELECT id, secteur FROM plateformes_referentiel WHERE secteur IS NOT NULL').all(); + + db.transaction(() => { + for (const ref of refs) { + const label = SECTEURS_LABELS[ref.secteur] || ref.secteur; + const sect = getSectId.get(label); + if (sect) insSectLink.run(ref.id, sect.id); + } + })(); + } +} + +// ── Migration : domiciliation texte → code ISO 3166-1 alpha-2 ──────────────── +{ + const OLD_VALUES = ['france', 'zone_europeenne', 'hors_zone_europeenne']; + + // plateformes (par user) + const hasOldPlat = db.prepare( + `SELECT 1 FROM plateformes WHERE domiciliation IN ('france','zone_europeenne','hors_zone_europeenne') LIMIT 1` + ).get(); + if (hasOldPlat) { + db.exec(`UPDATE plateformes SET domiciliation = 'FR' WHERE domiciliation = 'france'`); + // zone_europeenne / hors_zone_europeenne → 'EU' provisoire, à corriger via l'interface + db.exec(`UPDATE plateformes SET domiciliation = 'EU' WHERE domiciliation = 'zone_europeenne'`); + db.exec(`UPDATE plateformes SET domiciliation = 'EU' WHERE domiciliation = 'hors_zone_europeenne'`); + console.log('[DB] Migration domiciliation → ISO : plateformes mises à jour.'); + } + + // plateformes_referentiel (admin) + const hasOldRef = db.prepare( + `SELECT 1 FROM plateformes_referentiel WHERE domiciliation IN ('france','zone_europeenne','hors_zone_europeenne') LIMIT 1` + ).get(); + if (hasOldRef) { + db.exec(`UPDATE plateformes_referentiel SET domiciliation = 'FR' WHERE domiciliation = 'france'`); + db.exec(`UPDATE plateformes_referentiel SET domiciliation = 'EU' WHERE domiciliation = 'zone_europeenne'`); + db.exec(`UPDATE plateformes_referentiel SET domiciliation = 'EU' WHERE domiciliation = 'hors_zone_europeenne'`); + console.log('[DB] Migration domiciliation → ISO : référentiel mis à jour.'); + } +} + +// ── Migration : pays_siege → domiciliation (ISO) sur plateformes_referentiel ── +{ + const NAME_TO_ISO = { + 'Afghanistan': 'AF', 'Afrique du Sud': 'ZA', 'Albanie': 'AL', 'Algérie': 'DZ', + 'Allemagne': 'DE', 'Andorre': 'AD', 'Angola': 'AO', 'Antigua-et-Barbuda': 'AG', + 'Arabie saoudite': 'SA', 'Argentine': 'AR', 'Arménie': 'AM', 'Australie': 'AU', + 'Autriche': 'AT', 'Azerbaïdjan': 'AZ', 'Bahamas': 'BS', 'Bahreïn': 'BH', + 'Bangladesh': 'BD', 'Barbade': 'BB', 'Biélorussie': 'BY', 'Belgique': 'BE', + 'Belize': 'BZ', 'Bénin': 'BJ', 'Bhoutan': 'BT', 'Bolivie': 'BO', + 'Bosnie-Herzégovine': 'BA', 'Botswana': 'BW', 'Brésil': 'BR', 'Brunéi': 'BN', + 'Bulgarie': 'BG', 'Burkina Faso': 'BF', 'Burundi': 'BI', 'Cap-Vert': 'CV', + 'Cambodge': 'KH', 'Cameroun': 'CM', 'Canada': 'CA', 'République centrafricaine': 'CF', + 'Chili': 'CL', 'Chine': 'CN', 'Chypre': 'CY', 'Colombie': 'CO', + 'Comores': 'KM', 'Congo': 'CG', 'Congo (RDC)': 'CD', 'Corée du Nord': 'KP', + 'Corée du Sud': 'KR', 'Costa Rica': 'CR', 'Croatie': 'HR', 'Cuba': 'CU', + 'Danemark': 'DK', 'Djibouti': 'DJ', 'République dominicaine': 'DO', 'Dominique': 'DM', + 'Égypte': 'EG', 'Salvador': 'SV', 'Émirats arabes unis': 'AE', 'Équateur': 'EC', + 'Érythrée': 'ER', 'Espagne': 'ES', 'Estonie': 'EE', 'Eswatini': 'SZ', + 'Éthiopie': 'ET', 'Fidji': 'FJ', 'Finlande': 'FI', 'France': 'FR', + 'Gabon': 'GA', 'Gambie': 'GM', 'Géorgie': 'GE', 'Ghana': 'GH', + 'Grèce': 'GR', 'Grenade': 'GD', 'Guatemala': 'GT', 'Guinée': 'GN', + 'Guinée-Bissau': 'GW', 'Guinée équatoriale': 'GQ', 'Guyana': 'GY', 'Haïti': 'HT', + 'Honduras': 'HN', 'Hongrie': 'HU', 'Inde': 'IN', 'Indonésie': 'ID', + 'Irak': 'IQ', 'Iran': 'IR', 'Irlande': 'IE', 'Islande': 'IS', + 'Israël': 'IL', 'Italie': 'IT', 'Jamaïque': 'JM', 'Japon': 'JP', + 'Jordanie': 'JO', 'Kazakhstan': 'KZ', 'Kenya': 'KE', 'Kirghizistan': 'KG', + 'Kiribati': 'KI', 'Koweït': 'KW', 'Laos': 'LA', 'Lesotho': 'LS', + 'Lettonie': 'LV', 'Liban': 'LB', 'Liberia': 'LR', 'Libye': 'LY', + 'Liechtenstein': 'LI', 'Lituanie': 'LT', 'Luxembourg': 'LU', 'Macédoine du Nord': 'MK', + 'Madagascar': 'MG', 'Malaisie': 'MY', 'Malawi': 'MW', 'Maldives': 'MV', + 'Mali': 'ML', 'Malte': 'MT', 'Maroc': 'MA', 'Îles Marshall': 'MH', + 'Maurice': 'MU', 'Mauritanie': 'MR', 'Mexique': 'MX', 'Micronésie': 'FM', + 'Moldavie': 'MD', 'Monaco': 'MC', 'Mongolie': 'MN', 'Monténégro': 'ME', + 'Mozambique': 'MZ', 'Myanmar': 'MM', 'Namibie': 'NA', 'Nauru': 'NR', + 'Népal': 'NP', 'Nicaragua': 'NI', 'Niger': 'NE', 'Nigeria': 'NG', + 'Norvège': 'NO', 'Nouvelle-Zélande': 'NZ', 'Oman': 'OM', 'Ouganda': 'UG', + 'Ouzbékistan': 'UZ', 'Pakistan': 'PK', 'Palaos': 'PW', 'Panama': 'PA', + 'Papouasie-Nouvelle-Guinée': 'PG', 'Paraguay': 'PY', 'Pays-Bas': 'NL', 'Pérou': 'PE', + 'Philippines': 'PH', 'Pologne': 'PL', 'Portugal': 'PT', 'Qatar': 'QA', + 'Roumanie': 'RO', 'Royaume-Uni': 'GB', 'Russie': 'RU', 'Rwanda': 'RW', + 'Saint-Kitts-et-Nevis': 'KN', 'Saint-Marin': 'SM', 'Saint-Vincent-et-les-Grenadines': 'VC', + 'Sainte-Lucie': 'LC', 'Îles Salomon': 'SB', 'Samoa': 'WS', 'Sao Tomé-et-Principe': 'ST', + 'Sénégal': 'SN', 'Serbie': 'RS', 'Seychelles': 'SC', 'Sierra Leone': 'SL', + 'Singapour': 'SG', 'Slovaquie': 'SK', 'Slovénie': 'SI', 'Somalie': 'SO', + 'Soudan': 'SD', 'Soudan du Sud': 'SS', 'Sri Lanka': 'LK', 'Suède': 'SE', + 'Suisse': 'CH', 'Suriname': 'SR', 'Syrie': 'SY', 'Tadjikistan': 'TJ', + 'Tanzanie': 'TZ', 'Tchad': 'TD', 'Tchéquie': 'CZ', 'Thaïlande': 'TH', + 'Timor oriental': 'TL', 'Togo': 'TG', 'Tonga': 'TO', 'Trinité-et-Tobago': 'TT', + 'Tunisie': 'TN', 'Turkménistan': 'TM', 'Turquie': 'TR', 'Tuvalu': 'TV', + 'Ukraine': 'UA', 'Uruguay': 'UY', 'Vanuatu': 'VU', 'Venezuela': 'VE', + 'Viêt Nam': 'VN', 'Yémen': 'YE', 'Zambie': 'ZM', 'Zimbabwe': 'ZW', + 'États-Unis': 'US', + }; + + const rows = db.prepare( + `SELECT id, pays_siege, domiciliation FROM plateformes_referentiel WHERE pays_siege IS NOT NULL` + ).all(); + + const stmt = db.prepare(`UPDATE plateformes_referentiel SET domiciliation = ? WHERE id = ?`); + let updated = 0, unmatched = []; + + for (const row of rows) { + const iso = NAME_TO_ISO[row.pays_siege]; + if (iso && iso !== row.domiciliation) { + stmt.run(iso, row.id); + updated++; + } else if (!iso) { + unmatched.push(row.pays_siege); + } + } + + if (updated > 0) + console.log(`[DB] Migration pays_siege → domiciliation : ${updated} ligne(s) mise(s) à jour.`); + if (unmatched.length > 0) + console.warn(`[DB] Migration pays_siege → domiciliation : valeurs non reconnues →`, [...new Set(unmatched)]); +} + +// ── Migration : catégories/secteurs par user + associations plateformes/investissements ── +{ + // 1. Ajouter user_id sur categories_inv (NULL = global admin) + const colsCatInv = db.prepare("PRAGMA table_info(categories_inv)").all().map(c => c.name); + if (!colsCatInv.includes('user_id')) { + db.exec('ALTER TABLE categories_inv ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE'); + console.log('[DB] categories_inv.user_id ajouté'); + } + + // 2. Ajouter user_id sur secteurs_inv (NULL = global admin) + const colsSectInv = db.prepare("PRAGMA table_info(secteurs_inv)").all().map(c => c.name); + if (!colsSectInv.includes('user_id')) { + db.exec('ALTER TABLE secteurs_inv ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE'); + console.log('[DB] secteurs_inv.user_id ajouté'); + } + + // 3. Table associations plateforme ↔ catégories + db.exec(` + CREATE TABLE IF NOT EXISTS plateforme_categories_inv ( + plateforme_id INTEGER NOT NULL REFERENCES plateformes(id) ON DELETE CASCADE, + categorie_id INTEGER NOT NULL REFERENCES categories_inv(id) ON DELETE CASCADE, + PRIMARY KEY (plateforme_id, categorie_id) + ) + `); + + // 4. Table associations plateforme ↔ secteurs + db.exec(` + CREATE TABLE IF NOT EXISTS plateforme_secteurs_inv ( + plateforme_id INTEGER NOT NULL REFERENCES plateformes(id) ON DELETE CASCADE, + secteur_id INTEGER NOT NULL REFERENCES secteurs_inv(id) ON DELETE CASCADE, + PRIMARY KEY (plateforme_id, secteur_id) + ) + `); + + // 5. Table associations investissement ↔ catégories + db.exec(` + CREATE TABLE IF NOT EXISTS investissement_categories_inv ( + investissement_id INTEGER NOT NULL REFERENCES investissements(id) ON DELETE CASCADE, + categorie_id INTEGER NOT NULL REFERENCES categories_inv(id) ON DELETE CASCADE, + PRIMARY KEY (investissement_id, categorie_id) + ) + `); + + // 6. Table associations investissement ↔ secteurs + db.exec(` + CREATE TABLE IF NOT EXISTS investissement_secteurs_inv ( + investissement_id INTEGER NOT NULL REFERENCES investissements(id) ON DELETE CASCADE, + secteur_id INTEGER NOT NULL REFERENCES secteurs_inv(id) ON DELETE CASCADE, + PRIMARY KEY (investissement_id, secteur_id) + ) + `); + + console.log('[DB] Tables catégories/secteurs plateforme+investissement OK'); +} + +// ── Migration ponctuelle : correction date_cible aberrantes (>2100) ────────── +// Certains prêts différés importés ont une date_cible avec un siècle erroné. +// On recalcule date_souscription + duree_mois et on régénère la simulation. +{ + function fixAddMonths(isoDate, months) { + const [y, m, d] = isoDate.split('-').map(Number); + let nm = m + months; + let ny = y; + while (nm > 12) { nm -= 12; ny++; } + const maxDay = new Date(Date.UTC(ny, nm, 0)).getUTCDate(); + const nd = Math.min(d, maxDay); + return `${String(ny).padStart(4,'0')}-${String(nm).padStart(2,'0')}-${String(nd).padStart(2,'0')}`; + } + + const toFix = db.prepare(` + SELECT i.id, i.date_souscription, i.duree_mois, + i.montant_investi, i.taux_interet, i.type_remb, i.freq_interets, + i.date_premiere_echeance, i.date_debut_simul, i.echeance_fin_de_mois + FROM investissements i + WHERE i.statut IN ('en_cours','en_retard','procedure') + AND i.type_remb = 'differe' + AND i.date_cible > '2100-01-01' + AND i.duree_mois IS NOT NULL + `).all(); + + if (toFix.length > 0) { + const updateDate = db.prepare(`UPDATE investissements SET date_cible=?, updated_at=datetime('now') WHERE id=?`); + const fixAll = db.transaction(() => { + for (const inv of toFix) { + const newDate = fixAddMonths(inv.date_souscription, inv.duree_mois); + updateDate.run(newDate, inv.id); + generateSimul(db, { ...inv, date_cible: newDate }); + console.log(`[DB] Fix date_cible id=${inv.id} → ${newDate}`); + } + }); + fixAll(); + console.log(`[DB] ${toFix.length} date_cible aberrantes corrigées.`); + } +} diff --git a/backend/src/db/init.js b/backend/src/db/init.js new file mode 100644 index 0000000..1eda943 --- /dev/null +++ b/backend/src/db/init.js @@ -0,0 +1,12 @@ +// Standalone DB initializer (run with: npm run db:init) +import 'dotenv/config'; +import db from './index.js'; + +console.log('SQLite database initialized.'); +console.log(`Tables: ${db + .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") + .all() + .map(r => r.name) + .join(', ')}`); + +db.close(); diff --git a/backend/src/db/schema.sql b/backend/src/db/schema.sql new file mode 100644 index 0000000..3cb7c5c --- /dev/null +++ b/backend/src/db/schema.sql @@ -0,0 +1,253 @@ +-- ===================================================================== +-- Crowdlending Tracker - SQLite schema +-- ===================================================================== +-- Conventions: +-- * Monetary amounts: REAL (cents-level precision OK for personal use) +-- * Dates: TEXT in ISO 8601 (YYYY-MM-DD) for stable sort & SQLite date() +-- * All tables use AUTOINCREMENT-free INTEGER PK (rowid alias) +-- * created_at/updated_at: TEXT ISO timestamp, set by app +-- ===================================================================== + +PRAGMA foreign_keys = ON; +PRAGMA journal_mode = WAL; + +-- --------------------------------------------------------------------- +-- USERS (login accounts) +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + display_name TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- --------------------------------------------------------------------- +-- INVESTISSEURS (profils d'investissement sous un même login) +-- Ex.: "Monsieur", "Madame", "SCI Croguennec", "PEA-PME", ... +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS investisseurs ( + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + nom TEXT NOT NULL, -- nom complet (famille) ou raison sociale (entreprise) + prenom TEXT, -- prénom (famille uniquement) + type TEXT NOT NULL DEFAULT 'famille' + CHECK(type IN ('famille','entreprise')), + type_fiscal TEXT, -- 'PP', 'PM', 'SCI', 'SCPI'... + notes TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(user_id, nom) +); +CREATE INDEX IF NOT EXISTS idx_investisseurs_user ON investisseurs(user_id); + +-- --------------------------------------------------------------------- +-- PLATEFORMES (ClubFunding, October, Lendix, La Première Brique, ...) +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS plateformes ( + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + nom TEXT NOT NULL, + url TEXT, + notes TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + domiciliation TEXT NOT NULL DEFAULT 'france', + fiscalite TEXT NOT NULL DEFAULT 'flat_tax', + taux_fiscalite_locale REAL, + methode_remboursement TEXT NOT NULL DEFAULT 'portefeuille', + investisseur_id INTEGER REFERENCES investisseurs(id) ON DELETE SET NULL, + date_ouverture TEXT, + logo_filename TEXT, + UNIQUE(user_id, nom, investisseur_id) +); +CREATE INDEX IF NOT EXISTS idx_plateformes_user ON plateformes(user_id); + +-- --------------------------------------------------------------------- +-- DEPOTS_RETRAITS (mouvements de cash sur les plateformes) +-- type: 'depot' (versement) | 'retrait' (retrait) +-- --------------------------------------------------------------------- +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, + 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), + libelle TEXT, + reference TEXT, -- ref bancaire / plateforme + source TEXT NOT NULL DEFAULT 'manuel', -- 'manuel' | 'import_excel' + notes TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +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); + +-- --------------------------------------------------------------------- +-- INVESTISSEMENTS (CF Investissements - liste des projets souscrits) +-- statut: 'en_cours' | 'rembourse' | 'en_retard' | 'procedure' | 'cloture' +-- --------------------------------------------------------------------- +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, + nom_projet TEXT NOT NULL, + emetteur TEXT, -- nom de la société emprunteuse + date_souscription TEXT NOT NULL, + date_premiere_echeance TEXT, -- date de la 1ère échéance (intérêts / remboursement) + date_cible TEXT, -- date contractuelle du dernier versement (calculée) + montant_investi REAL NOT NULL CHECK(montant_investi > 0), + taux_interet REAL, -- en % annuel (ex. 9.5) + duree_mois INTEGER, + type_remb TEXT, -- 'in_fine' | 'amortissable' | 'differe' + freq_interets TEXT NOT NULL DEFAULT 'mensuel', -- 'mensuel' | 'trimestriel' | 'in_fine' + statut TEXT NOT NULL DEFAULT 'en_cours' + CHECK(statut IN ('en_cours','rembourse','en_retard','procedure','cloture')), + reference TEXT, -- ID projet sur la plateforme + source TEXT NOT NULL DEFAULT 'manuel', + notes TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +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); + +-- --------------------------------------------------------------------- +-- REMBOURSEMENTS (échéances perçues, réelles) +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS remboursements ( + id INTEGER PRIMARY KEY, + investissement_id INTEGER NOT NULL REFERENCES investissements(id) ON DELETE CASCADE, + date_remb TEXT NOT NULL, + capital REAL NOT NULL DEFAULT 0, + interets_bruts REAL NOT NULL DEFAULT 0, -- intérêts AVANT prélèvements + prelev_sociaux REAL NOT NULL DEFAULT 0, -- 17.2 % typiquement + prelev_forfaitaire REAL NOT NULL DEFAULT 0, -- 12.8 % (PFU IR) + cashback REAL NOT NULL DEFAULT 0, -- remboursement non taxé (bonus plateforme, etc.) + interets_nets REAL NOT NULL DEFAULT 0, -- interets_bruts - prelev_sociaux - prelev_forfaitaire + net_recu REAL NOT NULL DEFAULT 0, -- capital + cashback + interets_nets + statut TEXT NOT NULL DEFAULT 'paye' + CHECK(statut IN ('paye','retard','partiel','impaye')), + source TEXT NOT NULL DEFAULT 'manuel', + notes TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_remb_inv ON remboursements(investissement_id); +CREATE INDEX IF NOT EXISTS idx_remb_date ON remboursements(date_remb); +CREATE INDEX IF NOT EXISTS idx_remb_statut ON remboursements(statut); + +-- --------------------------------------------------------------------- +-- SIMUL_REMBOURSEMENTS (échéances prévisionnelles / théoriques) +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS simul_remboursements ( + id INTEGER PRIMARY KEY, + investissement_id INTEGER NOT NULL REFERENCES investissements(id) ON DELETE CASCADE, + numero_echeance INTEGER NOT NULL, + date_prevue TEXT NOT NULL, + capital_prevu REAL NOT NULL DEFAULT 0, + interets_prevus REAL NOT NULL DEFAULT 0, + total_prevu REAL NOT NULL DEFAULT 0, + notes TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(investissement_id, numero_echeance) +); +CREATE INDEX IF NOT EXISTS idx_simul_inv ON simul_remboursements(investissement_id); +CREATE INDEX IF NOT EXISTS idx_simul_date ON simul_remboursements(date_prevue); + +-- --------------------------------------------------------------------- +-- IMPORTS (historique des imports Excel pour traçabilité) +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS imports ( + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + investisseur_id INTEGER REFERENCES investisseurs(id) ON DELETE SET NULL, + module TEXT NOT NULL, -- 'depots_retraits' | 'investissements' | ... + filename TEXT NOT NULL, + rows_total INTEGER NOT NULL DEFAULT 0, + rows_inserted INTEGER NOT NULL DEFAULT 0, + rows_skipped INTEGER NOT NULL DEFAULT 0, + mapping_json TEXT, -- JSON: mappage colonnes + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_imports_user ON imports(user_id); + +-- --------------------------------------------------------------------- +-- CATEGORIES_PLATEFORME (tags libres associés à une plateforme) +-- Semées par défaut à la première utilisation (voir route /categories) +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS categories_plateforme ( + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + nom TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(user_id, nom) +); +CREATE INDEX IF NOT EXISTS idx_catplat_user ON categories_plateforme(user_id); + +-- Junction : une plateforme peut avoir plusieurs catégories +CREATE TABLE IF NOT EXISTS plateforme_categories ( + plateforme_id INTEGER NOT NULL REFERENCES plateformes(id) ON DELETE CASCADE, + categorie_id INTEGER NOT NULL REFERENCES categories_plateforme(id) ON DELETE CASCADE, + PRIMARY KEY(plateforme_id, categorie_id) +); + +-- --------------------------------------------------------------------- +-- TAUX_PFU (historique Flat Tax France — table de référence globale) +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS taux_pfu ( + id INTEGER PRIMARY KEY, + annee INTEGER NOT NULL UNIQUE, + pfu_total REAL NOT NULL, -- ex. 30.0 + impot_revenu REAL NOT NULL, -- ex. 12.8 + prelev_sociaux REAL NOT NULL, -- ex. 17.2 + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_pfu_annee ON taux_pfu(annee); + +-- --------------------------------------------------------------------- +-- VUES utiles +-- --------------------------------------------------------------------- + +-- Solde courant par (investisseur, plateforme) +CREATE VIEW IF NOT EXISTS v_solde_plateforme AS +SELECT + dr.investisseur_id, + dr.plateforme_id, + SUM(CASE WHEN dr.type='depot' THEN dr.montant ELSE 0 END) AS total_depots, + SUM(CASE WHEN dr.type='retrait' THEN dr.montant ELSE 0 END) AS total_retraits, + SUM(CASE WHEN dr.type='depot' THEN dr.montant + WHEN dr.type='retrait' THEN -dr.montant END) AS solde_net +FROM depots_retraits dr +GROUP BY dr.investisseur_id, dr.plateforme_id; + +-- Synthèse investissements par investisseur +CREATE VIEW IF NOT EXISTS v_synthese_inv AS +SELECT + i.investisseur_id, + COUNT(*) AS nb_projets, + SUM(i.montant_investi) AS total_investi, + SUM(CASE WHEN i.statut='en_cours' THEN i.montant_investi ELSE 0 END) AS encours, + SUM(CASE WHEN i.statut='rembourse' THEN i.montant_investi ELSE 0 END) AS rembourse, + SUM(CASE WHEN i.statut IN ('en_retard','procedure') THEN i.montant_investi ELSE 0 END) AS en_defaut +FROM investissements i +GROUP BY i.investisseur_id; + +-- Intérêts perçus par année (pour le 2778-SD) +CREATE VIEW IF NOT EXISTS v_interets_annuels AS +SELECT + i.investisseur_id, + substr(r.date_remb,1,4) AS annee, + SUM(r.interets_bruts) AS interets_bruts, + SUM(r.prelev_sociaux) AS prelev_sociaux, + SUM(r.prelev_forfaitaire) AS prelev_forfaitaire, + SUM(r.net_recu) AS net_recu +FROM remboursements r +JOIN investissements i ON i.id = r.investissement_id +WHERE r.statut IN ('paye','partiel') +GROUP BY i.investisseur_id, substr(r.date_remb,1,4); diff --git a/backend/src/jobs/autoStatut.js b/backend/src/jobs/autoStatut.js new file mode 100644 index 0000000..4eb0957 --- /dev/null +++ b/backend/src/jobs/autoStatut.js @@ -0,0 +1,124 @@ +import db from '../db/index.js'; + +const JOB_NAME = 'auto_statut_retard'; + +/** 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('[autoStatut] Impossible d\'écrire dans job_logs :', 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é. + * + * @returns {number} nombre d'investissements mis à jour + */ +export function checkStatutsRetard() { + 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') + `).all(); + + if (candidats.length === 0) { + writeLog({ status: 'ok', nbChanges: 0, details: 'Aucun investissement en retard détecté' }); + return 0; + } + + const updateStmt = db.prepare(` + UPDATE investissements + SET statut = 'en_retard', updated_at = datetime('now') + WHERE id = ? + `); + + const histStmt = db.prepare(` + INSERT INTO investissement_historique + (investissement_id, type_evenement, changements, notes) + VALUES (?, 'passage_auto_retard', ?, ?) + `); + + 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_cours', + nouvelle_valeur: 'en_retard', + }]), + `Passage automatique : date cible (${inv.date_cible}) dépassée` + ); + } + }); + tx(); + + const details = candidats + .map(i => `"${i.nom_projet}" (id=${i.id}, date_cible=${i.date_cible})`) + .join('; '); + + writeLog({ + status: 'ok', + nbChanges: candidats.length, + details: `Passé en retard : ${details}`, + }); + + console.log(`[autoStatut] ${candidats.length} investissement(s) passé(s) en retard : ${details}`); + return candidats.length; +} + +/** + * Démarre le job de vérification automatique des statuts. + * + * - Exécution immédiate au démarrage (rattrape les retards accumulés + * pendant que le serveur était éteint). + * - Puis répétition quotidienne, calée sur la prochaine minuit locale + * afin de ne pas dériver au fil des redémarrages. + */ +export function startAutoStatutJob() { + // Exécution initiale + try { + checkStatutsRetard(); + } catch (err) { + console.error('[autoStatut] Erreur lors de la vérification initiale :', err); + writeLog({ status: 'error', nbChanges: 0, errorMsg: err.message }); + } + + // Calcule le délai jusqu'à la prochaine minuit locale + function msUntilMidnight() { + const now = new Date(); + const next = new Date(now); + next.setHours(24, 0, 0, 0); + return next.getTime() - now.getTime(); + } + + // Planifie la première échéance à minuit, puis toutes les 24h (sans dérive) + function scheduleDailyRun() { + setTimeout(() => { + try { + checkStatutsRetard(); + } catch (err) { + console.error('[autoStatut] Erreur lors de la vérification quotidienne :', err); + writeLog({ status: 'error', nbChanges: 0, errorMsg: err.message }); + } + scheduleDailyRun(); + }, msUntilMidnight()); + } + + scheduleDailyRun(); + console.log(`[autoStatut] Job démarré — prochaine vérification dans ${Math.round(msUntilMidnight() / 60000)} min (minuit)`); +} diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js new file mode 100644 index 0000000..ea9fcda --- /dev/null +++ b/backend/src/middleware/auth.js @@ -0,0 +1,35 @@ +import jwt from 'jsonwebtoken'; +import db from '../db/index.js'; + +const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-me'; + +export function signToken(payload) { + return jwt.sign(payload, JWT_SECRET, { + expiresIn: process.env.JWT_EXPIRES_IN || '7d', + }); +} + +export function requireAuth(req, res, next) { + const header = req.headers.authorization || ''; + const [scheme, token] = header.split(' '); + if (scheme !== 'Bearer' || !token) { + return res.status(401).json({ error: 'Missing or invalid Authorization header' }); + } + try { + const decoded = jwt.verify(token, JWT_SECRET); + req.user = { id: decoded.sub, email: decoded.email }; + next(); + } catch { + return res.status(401).json({ error: 'Invalid or expired token' }); + } +} + +/** À utiliser après requireAuth. Refuse l'accès si l'utilisateur n'est pas admin. */ +export function requireAdmin(req, res, next) { + const row = db.prepare('SELECT role FROM users WHERE id = ?').get(req.user.id); + if (!row || row.role !== 'admin') { + return res.status(403).json({ error: 'Accès réservé aux administrateurs' }); + } + req.user.role = 'admin'; + next(); +} diff --git a/backend/src/middleware/errorHandler.js b/backend/src/middleware/errorHandler.js new file mode 100644 index 0000000..c9fc92b --- /dev/null +++ b/backend/src/middleware/errorHandler.js @@ -0,0 +1,23 @@ +import { ZodError } from 'zod'; + +// eslint-disable-next-line no-unused-vars +export function errorHandler(err, req, res, next) { + if (err instanceof ZodError) { + return res.status(400).json({ + error: 'Validation error', + details: err.flatten(), + }); + } + if (err.status) { + return res.status(err.status).json({ error: err.message }); + } + console.error(err); + res.status(500).json({ error: 'Internal server error' }); +} + +export class HttpError extends Error { + constructor(status, message) { + super(message); + this.status = status; + } +} diff --git a/backend/src/middleware/investisseurScope.js b/backend/src/middleware/investisseurScope.js new file mode 100644 index 0000000..c6cc499 --- /dev/null +++ b/backend/src/middleware/investisseurScope.js @@ -0,0 +1,22 @@ +import db from '../db/index.js'; +import { HttpError } from './errorHandler.js'; + +/** + * Resolves the active investisseur from the X-Investisseur-Id header (or query), + * verifies it belongs to the authenticated user, and exposes it as req.investisseur. + * + * Routes that operate on investisseur-scoped data must use this middleware. + */ +export function requireInvestisseur(req, res, next) { + const raw = req.header('X-Investisseur-Id') || req.query.investisseurId; + const id = Number(raw); + if (!id) throw new HttpError(400, 'Missing investisseur id (header X-Investisseur-Id)'); + + const row = db + .prepare('SELECT id, nom, type_fiscal FROM investisseurs WHERE id = ? AND user_id = ?') + .get(id, req.user.id); + + if (!row) throw new HttpError(403, 'Investisseur not found or not owned by user'); + req.investisseur = row; + next(); +} diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js new file mode 100644 index 0000000..56cbfa0 --- /dev/null +++ b/backend/src/routes/admin.js @@ -0,0 +1,373 @@ +import { Router } from 'express'; +import bcrypt from 'bcryptjs'; +import { z } from 'zod'; +import db from '../db/index.js'; +import { HttpError } from '../middleware/errorHandler.js'; +import { checkStatutsRetard } from '../jobs/autoStatut.js'; + +// ── Helpers similarité de noms ──────────────────────────────────────────── */ + +/** Distance de Levenshtein entre deux chaînes */ +function levenshtein(a, b) { + const m = a.length, n = b.length; + const dp = Array.from({ length: m + 1 }, (_, i) => [i, ...Array(n).fill(0)]); + for (let j = 0; j <= n; j++) dp[0][j] = j; + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + dp[i][j] = a[i - 1] === b[j - 1] + ? dp[i - 1][j - 1] + : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]); + } + } + return dp[m][n]; +} + +/** Similarité normalisée [0..1] — insensible à la casse et aux accents */ +function normalize(s) { + return (s || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().trim(); +} +function similarity(a, b) { + const na = normalize(a), nb = normalize(b); + if (!na && !nb) return 1; + const maxLen = Math.max(na.length, nb.length); + return maxLen === 0 ? 1 : 1 - levenshtein(na, nb) / maxLen; +} + +const SIMILARITY_THRESHOLD = 0.80; + +// Registre des jobs disponibles (nom → fonction synchrone) +const JOBS = { + auto_statut_retard: checkStatutsRetard, +}; + +const router = Router(); +// requireAuth + requireAdmin sont appliqués dans server.js avant ce router + +/* ── Utilisateurs ─────────────────────────────────────────────────────── */ + +/** Liste tous les utilisateurs */ +router.get('/users', (req, res) => { + const users = db.prepare(` + SELECT id, email, display_name, role, created_at + FROM users + ORDER BY id ASC + `).all(); + res.json(users); +}); + +/** Crée un utilisateur */ +const CreateUserSchema = z.object({ + email: z.string().email(), + password: z.string().min(8), + displayName: z.string().min(1).optional(), + role: z.enum(['user', 'admin']).default('user'), +}); + +router.post('/users', (req, res, next) => { + try { + const body = CreateUserSchema.parse(req.body); + const exists = db.prepare('SELECT id FROM users WHERE email = ?').get(body.email); + if (exists) throw new HttpError(409, 'Email déjà utilisé'); + + const hash = bcrypt.hashSync(body.password, 10); + const result = db + .prepare('INSERT INTO users (email, password_hash, display_name, role) VALUES (?, ?, ?, ?)') + .run(body.email, hash, body.displayName || null, body.role); + + const userId = result.lastInsertRowid; + const fullName = body.displayName || body.email.split('@')[0]; + const prenom = fullName.includes(' ') ? fullName.split(' ')[0] : null; + db.prepare( + `INSERT INTO investisseurs (user_id, nom, prenom, type, type_fiscal) VALUES (?, ?, ?, 'famille', 'PP')` + ).run(userId, fullName, prenom); + + res.status(201).json({ id: userId, email: body.email, display_name: body.displayName || null, role: body.role }); + } catch (e) { next(e); } +}); + +/** Modifie le rôle d'un utilisateur */ +const PatchRoleSchema = z.object({ + role: z.enum(['user', 'admin']), +}); + +router.patch('/users/:id/role', (req, res, next) => { + try { + const { role } = PatchRoleSchema.parse(req.body); + const targetId = Number(req.params.id); + + // Empêche un admin de se rétrograder lui-même + if (targetId === req.user.id && role !== 'admin') { + throw new HttpError(400, 'Vous ne pouvez pas vous rétrograder vous-même'); + } + + const r = db.prepare("UPDATE users SET role=?, updated_at=datetime('now') WHERE id=?") + .run(role, targetId); + if (r.changes === 0) throw new HttpError(404, 'Utilisateur introuvable'); + + res.json({ id: targetId, role }); + } catch (e) { next(e); } +}); + +/** Supprime un utilisateur (sauf soi-même) */ +router.delete('/users/:id', (req, res, next) => { + try { + const targetId = Number(req.params.id); + if (targetId === req.user.id) { + throw new HttpError(400, 'Vous ne pouvez pas supprimer votre propre compte'); + } + const r = db.prepare('DELETE FROM users WHERE id = ?').run(targetId); + if (r.changes === 0) throw new HttpError(404, 'Utilisateur introuvable'); + res.status(204).end(); + } catch (e) { next(e); } +}); + +/* ── Logs des jobs ────────────────────────────────────────────────────── */ + +router.get('/job-logs', (req, res) => { + const limit = Math.min(Number(req.query.limit) || 100, 500); + const offset = Number(req.query.offset) || 0; + const job = req.query.job || null; + + const conds = job ? 'WHERE job_name = ?' : ''; + const params = job ? [job, limit, offset] : [limit, offset]; + + const rows = db.prepare(` + SELECT id, job_name, run_at, status, nb_changes, details, error_msg + FROM job_logs + ${conds} + ORDER BY run_at DESC + LIMIT ? OFFSET ? + `).all(...params); + + const total = db.prepare(`SELECT COUNT(*) AS n FROM job_logs ${conds}`) + .get(...(job ? [job] : [])).n; + + res.json({ total, rows }); +}); + +/* ── Plateformes orphelines (sans référentiel) ───────────────────────── */ + +/** Liste les plateformes user sans referentiel_id, avec suggestion de liaison si nom similaire */ +router.get('/plateformes-orphelines', (_req, res) => { + const rows = db.prepare(` + SELECT p.id, p.nom, p.domiciliation, p.fiscalite, p.created_at, + u.id AS user_id, u.email AS user_email, u.display_name AS user_display_name + FROM plateformes p + JOIN users u ON u.id = p.user_id + WHERE p.referentiel_id IS NULL + ORDER BY u.email, p.nom + `).all(); + + // Charger le référentiel pour calculer les suggestions de liaison + const refs = db.prepare('SELECT id, nom FROM plateformes_referentiel ORDER BY nom').all(); + + const enriched = rows.map(plat => { + let bestRef = null, bestScore = 0; + for (const ref of refs) { + const score = similarity(plat.nom, ref.nom); + if (score > bestScore) { bestScore = score; bestRef = ref; } + } + return { + ...plat, + suggestion: bestScore >= SIMILARITY_THRESHOLD + ? { referentiel_id: bestRef.id, referentiel_nom: bestRef.nom, score: Math.round(bestScore * 100) } + : null, + }; + }); + + res.json(enriched); +}); + +/** Importe une plateforme orpheline dans le référentiel et la lie */ +router.post('/plateformes-orphelines/:id/importer', (req, res, next) => { + try { + const plat = db.prepare(` + SELECT p.*, u.email AS user_email + FROM plateformes p + JOIN users u ON u.id = p.user_id + WHERE p.id = ? + `).get(req.params.id); + if (!plat) throw new HttpError(404, 'Plateforme introuvable'); + if (plat.referentiel_id) throw new HttpError(409, 'Plateforme deja liee a un referentiel'); + + // Verifier si un referentiel avec ce nom existe deja + const existing = db.prepare('SELECT id FROM plateformes_referentiel WHERE nom = ?').get(plat.nom); + if (existing) { + // Lier simplement la plateforme au referentiel existant + db.prepare( + "UPDATE plateformes SET referentiel_id = ?, overridden_fields = '[]' WHERE id = ?" + ).run(existing.id, plat.id); + const ref = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(existing.id); + return res.json({ linked: true, created: false, referentiel: ref }); + } + + // Creer un nouveau referentiel depuis la plateforme + db.transaction(() => { + const r = db.prepare(` + INSERT INTO plateformes_referentiel + (nom, url, domiciliation, fiscalite, taux_fiscalite_locale, type_produit_fiscal, logo_filename, updated_at) + VALUES (?,?,?,?,?,?,?, datetime('now')) + `).run( + plat.nom, plat.url || null, plat.domiciliation, plat.fiscalite, + plat.taux_fiscalite_locale ?? null, plat.type_produit_fiscal || '2TT', + plat.logo_filename ?? null + ); + const refId = r.lastInsertRowid; + + // Copier les categories (noms) depuis la plateforme source + const cats = db.prepare(` + SELECT cp.nom FROM plateforme_categories pc + JOIN categories_plateforme cp ON cp.id = pc.categorie_id + WHERE pc.plateforme_id = ? + `).all(plat.id); + const insC = db.prepare('INSERT OR IGNORE INTO referentiel_categories (referentiel_id, categorie_nom) VALUES (?,?)'); + for (const c of cats) insC.run(refId, c.nom); + + // Copier les criteres de notation + const notations = db.prepare('SELECT * FROM notation_criteres WHERE plateforme_id = ?').all(plat.id); + const insN = db.prepare(` + INSERT INTO referentiel_notation (referentiel_id, nom, type, valeurs, min_val, max_val, description, ordre) + VALUES (?,?,?,?,?,?,?,?) + `); + for (const n of notations) insN.run(refId, n.nom, n.type, n.valeurs, n.min_val, n.max_val, n.description, n.ordre); + + // Lier la plateforme au nouveau referentiel, overridden_fields vide + db.prepare( + "UPDATE plateformes SET referentiel_id = ?, overridden_fields = '[]' WHERE id = ?" + ).run(refId, plat.id); + })(); + + const ref = db.prepare('SELECT * FROM plateformes_referentiel WHERE nom = ?').get(plat.nom); + res.status(201).json({ linked: true, created: true, referentiel: ref }); + } catch (e) { next(e); } +}); + +/** Lie directement une plateforme orpheline à un référentiel existant (sans import) */ +router.post('/plateformes-orphelines/:id/lier', (req, res, next) => { + try { + const { referentiel_id } = req.body; + if (!referentiel_id) throw new HttpError(400, 'referentiel_id requis'); + + const plat = db.prepare('SELECT id, referentiel_id FROM plateformes WHERE id = ?').get(req.params.id); + if (!plat) throw new HttpError(404, 'Plateforme introuvable'); + if (plat.referentiel_id) throw new HttpError(409, 'Plateforme déjà liée à un référentiel'); + + const ref = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(referentiel_id); + if (!ref) throw new HttpError(404, 'Référentiel introuvable'); + + db.prepare( + "UPDATE plateformes SET referentiel_id = ?, overridden_fields = '[]' WHERE id = ?" + ).run(ref.id, plat.id); + + res.json({ linked: true, referentiel: ref }); + } catch (e) { next(e); } +}); + +/* -- Execution manuelle d'un job ----------------------------------------- */ + +router.post('/jobs/:name/run', (req, res, next) => { + try { + const { name } = req.params; + const fn = JOBS[name]; + if (!fn) throw new HttpError(404, `Job inconnu : ${name}`); + + const nbChanges = fn(); + const lastLog = db.prepare( + 'SELECT * FROM job_logs WHERE job_name = ? ORDER BY run_at DESC LIMIT 1' + ).get(name); + + res.json({ ok: true, nb_changes: nbChanges, log: lastLog }); + } catch (e) { next(e); } +}); + +export default router; + +/* ── Catégories & secteurs suggérés par les utilisateurs ─────────────── */ + +/** Compte les catégories et secteurs créés par les utilisateurs (non globaux) */ +router.get('/inv-suggestions-count', (_req, res) => { + const cats = db.prepare('SELECT COUNT(*) AS n FROM categories_inv WHERE user_id IS NOT NULL').get().n; + const sects = db.prepare('SELECT COUNT(*) AS n FROM secteurs_inv WHERE user_id IS NOT NULL').get().n; + res.json({ cats, sects, total: cats + sects }); +}); + +/** Liste toutes les catégories et secteurs créés par les utilisateurs */ +router.get('/inv-suggestions', (_req, res) => { + const categories = db.prepare(` + SELECT c.id, c.nom, c.user_id, u.email, u.display_name, + (SELECT COUNT(*) FROM plateforme_categories_inv WHERE categorie_id = c.id) AS nb_plateformes, + (SELECT COUNT(*) FROM investissement_categories_inv WHERE categorie_id = c.id) AS nb_investissements + FROM categories_inv c + JOIN users u ON u.id = c.user_id + WHERE c.user_id IS NOT NULL + ORDER BY u.email, c.nom + `).all(); + + const secteurs = db.prepare(` + SELECT s.id, s.nom, s.user_id, u.email, u.display_name, + (SELECT COUNT(*) FROM plateforme_secteurs_inv WHERE secteur_id = s.id) AS nb_plateformes, + (SELECT COUNT(*) FROM investissement_secteurs_inv WHERE secteur_id = s.id) AS nb_investissements + FROM secteurs_inv s + JOIN users u ON u.id = s.user_id + WHERE s.user_id IS NOT NULL + ORDER BY u.email, s.nom + `).all(); + + res.json({ categories, secteurs }); +}); + +/** Promeut une catégorie utilisateur en catégorie globale (user_id → NULL) */ +router.post('/inv-suggestions/categories/:id/promouvoir', (req, res, next) => { + try { + const row = db.prepare('SELECT id, nom, user_id FROM categories_inv WHERE id = ?').get(req.params.id); + if (!row) throw new HttpError(404, 'Catégorie introuvable'); + if (row.user_id === null) throw new HttpError(400, 'Déjà globale'); + const dup = db.prepare('SELECT id FROM categories_inv WHERE nom = ? AND user_id IS NULL').get(row.nom); + if (dup) throw new HttpError(409, `Une catégorie globale "${row.nom}" existe déjà.`); + db.prepare('UPDATE categories_inv SET user_id = NULL WHERE id = ?').run(row.id); + res.json({ ok: true, msg: `"${row.nom}" promue en catégorie globale.` }); + } catch (e) { next(e); } +}); + +/** Supprime une catégorie suggérée */ +router.delete('/inv-suggestions/categories/:id', (req, res, next) => { + try { + const row = db.prepare('SELECT id, nom, user_id FROM categories_inv WHERE id = ?').get(req.params.id); + if (!row) throw new HttpError(404, 'Catégorie introuvable'); + if (row.user_id === null) throw new HttpError(403, 'Ne peut pas supprimer une catégorie globale depuis cette route.'); + db.transaction(() => { + db.prepare('DELETE FROM plateforme_categories_inv WHERE categorie_id = ?').run(row.id); + db.prepare('DELETE FROM investissement_categories_inv WHERE categorie_id = ?').run(row.id); + db.prepare('DELETE FROM categories_inv WHERE id = ?').run(row.id); + })(); + res.json({ ok: true, msg: `"${row.nom}" supprimée.` }); + } catch (e) { next(e); } +}); + +/** Promeut un secteur utilisateur en secteur global */ +router.post('/inv-suggestions/secteurs/:id/promouvoir', (req, res, next) => { + try { + const row = db.prepare('SELECT id, nom, user_id FROM secteurs_inv WHERE id = ?').get(req.params.id); + if (!row) throw new HttpError(404, 'Secteur introuvable'); + if (row.user_id === null) throw new HttpError(400, 'Déjà global'); + const dup = db.prepare('SELECT id FROM secteurs_inv WHERE nom = ? AND user_id IS NULL').get(row.nom); + if (dup) throw new HttpError(409, `Un secteur global "${row.nom}" existe déjà.`); + db.prepare('UPDATE secteurs_inv SET user_id = NULL WHERE id = ?').run(row.id); + res.json({ ok: true, msg: `"${row.nom}" promu en secteur global.` }); + } catch (e) { next(e); } +}); + +/** Supprime un secteur suggéré */ +router.delete('/inv-suggestions/secteurs/:id', (req, res, next) => { + try { + const row = db.prepare('SELECT id, nom, user_id FROM secteurs_inv WHERE id = ?').get(req.params.id); + if (!row) throw new HttpError(404, 'Secteur introuvable'); + if (row.user_id === null) throw new HttpError(403, 'Ne peut pas supprimer un secteur global depuis cette route.'); + db.transaction(() => { + db.prepare('DELETE FROM plateforme_secteurs_inv WHERE secteur_id = ?').run(row.id); + db.prepare('DELETE FROM investissement_secteurs_inv WHERE secteur_id = ?').run(row.id); + db.prepare('DELETE FROM secteurs_inv WHERE id = ?').run(row.id); + })(); + res.json({ ok: true, msg: `"${row.nom}" supprimé.` }); + } catch (e) { next(e); } +}); diff --git a/backend/src/routes/associations-inv.js b/backend/src/routes/associations-inv.js new file mode 100644 index 0000000..d1643b1 --- /dev/null +++ b/backend/src/routes/associations-inv.js @@ -0,0 +1,281 @@ +/** + * Routes associations catégories/secteurs pour plateformes et investissements. + * + * GET /api/plateformes/:id/categories-inv + * PUT /api/plateformes/:id/categories-inv { ids: [1,2,3] } + * GET /api/plateformes/:id/secteurs-inv + * PUT /api/plateformes/:id/secteurs-inv { ids: [1,2,3] } + * + * GET /api/investissements/:id/categories-inv + * PUT /api/investissements/:id/categories-inv { ids: [1,2,3] } + * GET /api/investissements/:id/secteurs-inv + * PUT /api/investissements/:id/secteurs-inv { ids: [1,2,3] } + * + * Toutes ces routes sont montées sous requireAuth. + * La vérification de propriété (via investisseur.user_id) est faite sur chaque route. + */ + +import { Router } from 'express'; +import { z } from 'zod'; +import db from '../db/index.js'; +import { HttpError } from '../middleware/errorHandler.js'; + +const router = Router(); + +// ── Helpers ──────────────────────────────────────────────────────────────── + +/** Vérifie que la plateforme appartient à l'utilisateur connecté. */ +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 = ? + `).get(platId, userId); + if (!row) throw new HttpError(404, 'Plateforme introuvable ou accès refusé.'); + return row; +} + +/** Vérifie que l'investissement appartient à l'utilisateur connecté. */ +function checkInvOwner(invId, userId) { + const row = db.prepare(` + SELECT inv.id, inv.plateforme_id FROM investissements inv + JOIN investisseurs i ON i.id = inv.investisseur_id + WHERE inv.id = ? AND i.user_id = ? + `).get(invId, userId); + if (!row) throw new HttpError(404, 'Investissement introuvable ou accès refusé.'); + return row; +} + +/** Vérifie que les ids fournis sont accessibles à l'utilisateur (globaux ou privés). */ +function checkCatIds(ids, userId) { + for (const id of ids) { + const row = db.prepare( + 'SELECT id FROM categories_inv WHERE id = ? AND (user_id IS NULL OR user_id = ?)' + ).get(id, userId); + if (!row) throw new HttpError(400, `Catégorie ${id} introuvable ou non autorisée.`); + } +} + +function checkSectIds(ids, userId) { + for (const id of ids) { + const row = db.prepare( + 'SELECT id FROM secteurs_inv WHERE id = ? AND (user_id IS NULL OR user_id = ?)' + ).get(id, userId); + if (!row) throw new HttpError(400, `Secteur ${id} introuvable ou non autorisé.`); + } +} + +const IdsSchema = z.object({ ids: z.array(z.number().int()).default([]) }); + +// ── Plateforme → catégories ──────────────────────────────────────────────── + +router.get('/plateformes/:id/categories-inv', (req, res, next) => { + try { + checkPlatOwner(req.params.id, req.user.id); + const rows = db.prepare(` + SELECT c.id, c.nom, CASE WHEN c.user_id IS NULL THEN 1 ELSE 0 END AS is_global, 0 AS is_inherited + FROM plateforme_categories_inv pc + JOIN categories_inv c ON c.id = pc.categorie_id + WHERE pc.plateforme_id = ? + 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); + if (plat?.referentiel_id) { + 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); + 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)); + for (const rc of refCats) { + if (!ownIds.has(rc.id)) rows.push({ ...rc, is_inherited: 1 }); + } + rows.sort((a, b) => a.nom.localeCompare(b.nom)); + } + res.json(rows); + } catch (e) { next(e); } +}); + +router.put('/plateformes/:id/categories-inv', (req, res, next) => { + try { + const { ids } = IdsSchema.parse(req.body); + const userId = req.user.id; + checkPlatOwner(req.params.id, userId); + checkCatIds(ids, userId); + + // Toujours inclure les tags hérités du référentiel + const plat = db.prepare('SELECT referentiel_id FROM plateformes WHERE id = ?').get(req.params.id); + let allIds = [...ids]; + 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])]; + } + + 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); + })(); + res.json({ ok: true }); + } catch (e) { next(e); } +}); + +// ── Plateforme → secteurs ────────────────────────────────────────────────── + +router.get('/plateformes/:id/secteurs-inv', (req, res, next) => { + try { + checkPlatOwner(req.params.id, req.user.id); + const rows = db.prepare(` + SELECT s.id, s.nom, CASE WHEN s.user_id IS NULL THEN 1 ELSE 0 END AS is_global, 0 AS is_inherited + FROM plateforme_secteurs_inv ps + JOIN secteurs_inv s ON s.id = ps.secteur_id + WHERE ps.plateforme_id = ? + 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); + if (plat?.referentiel_id) { + 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); + 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)); + for (const rs of refSects) { + if (!ownIds.has(rs.id)) rows.push({ ...rs, is_inherited: 1 }); + } + rows.sort((a, b) => a.nom.localeCompare(b.nom)); + } + res.json(rows); + } catch (e) { next(e); } +}); + +router.put('/plateformes/:id/secteurs-inv', (req, res, next) => { + try { + const { ids } = IdsSchema.parse(req.body); + const userId = req.user.id; + checkPlatOwner(req.params.id, userId); + checkSectIds(ids, userId); + + const plat = db.prepare('SELECT referentiel_id FROM plateformes WHERE id = ?').get(req.params.id); + let allIds = [...ids]; + 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])]; + } + + 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); + })(); + res.json({ ok: true }); + } catch (e) { next(e); } +}); + +// ── Investissement → catégories ──────────────────────────────────────────── + +router.get('/investissements/:id/categories-inv', (req, res, next) => { + try { + checkInvOwner(req.params.id, req.user.id); + const rows = db.prepare(` + SELECT c.id, c.nom, CASE WHEN c.user_id IS NULL THEN 1 ELSE 0 END AS is_global + FROM investissement_categories_inv ic + JOIN categories_inv c ON c.id = ic.categorie_id + WHERE ic.investissement_id = ? + ORDER BY is_global DESC, c.nom + `).all(req.params.id); + res.json(rows); + } catch (e) { next(e); } +}); + +router.put('/investissements/:id/categories-inv', (req, res, next) => { + try { + const { ids } = IdsSchema.parse(req.body); + const userId = req.user.id; + checkInvOwner(req.params.id, userId); + checkCatIds(ids, userId); + db.transaction(() => { + db.prepare('DELETE FROM investissement_categories_inv WHERE investissement_id = ?').run(req.params.id); + const ins = db.prepare('INSERT INTO investissement_categories_inv (investissement_id, categorie_id) VALUES (?, ?)'); + for (const id of ids) ins.run(req.params.id, id); + })(); + res.json({ ok: true }); + } catch (e) { next(e); } +}); + +// ── Investissement → secteurs ────────────────────────────────────────────── + +router.get('/investissements/:id/secteurs-inv', (req, res, next) => { + try { + checkInvOwner(req.params.id, req.user.id); + const rows = db.prepare(` + SELECT s.id, s.nom, CASE WHEN s.user_id IS NULL THEN 1 ELSE 0 END AS is_global + FROM investissement_secteurs_inv is2 + JOIN secteurs_inv s ON s.id = is2.secteur_id + WHERE is2.investissement_id = ? + ORDER BY is_global DESC, s.nom + `).all(req.params.id); + res.json(rows); + } catch (e) { next(e); } +}); + +router.put('/investissements/:id/secteurs-inv', (req, res, next) => { + try { + const { ids } = IdsSchema.parse(req.body); + const userId = req.user.id; + checkInvOwner(req.params.id, userId); + checkSectIds(ids, userId); + db.transaction(() => { + db.prepare('DELETE FROM investissement_secteurs_inv WHERE investissement_id = ?').run(req.params.id); + const ins = db.prepare('INSERT INTO investissement_secteurs_inv (investissement_id, secteur_id) VALUES (?, ?)'); + for (const id of ids) ins.run(req.params.id, id); + })(); + res.json({ ok: true }); + } catch (e) { next(e); } +}); + +// ── Helpers sync ────────────────────────────────────────────────────────── + +/** + * Quand les catégories d'une plateforme changent, resynchronise tous + * les investissements actifs de cette plateforme. + * Appelé dans une transaction existante. + */ +export function syncInvestissementsCategories(platId, catIds) { + const invs = db.prepare( + "SELECT id FROM investissements WHERE plateforme_id = ? AND statut NOT IN ('rembourse','cloture')" + ).all(platId); + const del = db.prepare('DELETE FROM investissement_categories_inv WHERE investissement_id = ?'); + const ins = db.prepare('INSERT OR IGNORE INTO investissement_categories_inv (investissement_id, categorie_id) VALUES (?, ?)'); + for (const { id } of invs) { + del.run(id); + for (const catId of catIds) ins.run(id, catId); + } +} + +export function syncInvestissementsSecteurs(platId, sectIds) { + const invs = db.prepare( + "SELECT id FROM investissements WHERE plateforme_id = ? AND statut NOT IN ('rembourse','cloture')" + ).all(platId); + const del = db.prepare('DELETE FROM investissement_secteurs_inv WHERE investissement_id = ?'); + const ins = db.prepare('INSERT OR IGNORE INTO investissement_secteurs_inv (investissement_id, secteur_id) VALUES (?, ?)'); + for (const { id } of invs) { + del.run(id); + for (const sectId of sectIds) ins.run(id, sectId); + } +} + +export default router; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js new file mode 100644 index 0000000..9eb4ba7 --- /dev/null +++ b/backend/src/routes/auth.js @@ -0,0 +1,133 @@ +import { Router } from 'express'; +import bcrypt from 'bcryptjs'; +import { z } from 'zod'; +import db from '../db/index.js'; +import { signToken, requireAuth } from '../middleware/auth.js'; +import { HttpError } from '../middleware/errorHandler.js'; + +const router = Router(); + +const RegisterSchema = z.object({ + email: z.string().email(), + password: z.string().min(8), + displayName: z.string().min(1).optional(), +}); + +const LoginSchema = z.object({ + email: z.string().email(), + password: z.string().min(1), +}); + +router.post('/register', (req, res, next) => { + try { + const body = RegisterSchema.parse(req.body); + const exists = db.prepare('SELECT id FROM users WHERE email = ?').get(body.email); + if (exists) throw new HttpError(409, 'Email already registered'); + + // Le premier utilisateur inscrit devient automatiquement administrateur + const isFirst = db.prepare('SELECT COUNT(*) AS n FROM users').get().n === 0; + const role = isFirst ? 'admin' : 'user'; + + const hash = bcrypt.hashSync(body.password, 10); + const result = db + .prepare('INSERT INTO users (email, password_hash, display_name, role) VALUES (?, ?, ?, ?)') + .run(body.email, hash, body.displayName || null, role); + + const userId = result.lastInsertRowid; + + // Auto-create le premier profil famille (= l'utilisateur lui-même) + const fullName = body.displayName || 'Mon profil'; + const prenom = fullName.includes(' ') ? fullName.split(' ')[0] : null; + const invResult = db.prepare( + `INSERT INTO investisseurs (user_id, nom, prenom, type, type_fiscal, is_principal) VALUES (?, ?, ?, 'famille', 'PP', 1)` + ).run(userId, fullName, prenom); + + // Auto-créer un compte courant pour le profil principal + db.prepare( + 'INSERT INTO comptes (user_id, nom, type, investisseur_id) VALUES (?,?,?,?)' + ).run(userId, 'Compte courant', 'compte_courant', invResult.lastInsertRowid); + + const token = signToken({ sub: userId, email: body.email }); + res.status(201).json({ + token, + user: { id: userId, email: body.email, displayName: body.displayName || null, role }, + }); + } catch (e) { next(e); } +}); + +router.post('/login', (req, res, next) => { + try { + const body = LoginSchema.parse(req.body); + const user = db + .prepare('SELECT id, email, password_hash, display_name, role FROM users WHERE email = ?') + .get(body.email); + if (!user) throw new HttpError(401, 'Invalid credentials'); + + const ok = bcrypt.compareSync(body.password, user.password_hash); + if (!ok) throw new HttpError(401, 'Invalid credentials'); + + const token = signToken({ sub: user.id, email: user.email }); + res.json({ + token, + user: { id: user.id, email: user.email, displayName: user.display_name, role: user.role }, + }); + } catch (e) { next(e); } +}); + +router.get('/me', requireAuth, (req, res) => { + const user = db + .prepare('SELECT id, email, display_name, role FROM users WHERE id = ?') + .get(req.user.id); + res.json({ user }); +}); + +const UpdateMeSchema = z.object({ + displayName: z.string().min(1).max(80).optional(), + email: z.string().email().optional(), + currentPassword: z.string().optional(), + newPassword: z.string().min(8).optional(), +}); + +router.put('/me', requireAuth, (req, res, next) => { + try { + const body = UpdateMeSchema.parse(req.body); + + const current = db + .prepare('SELECT id, email, password_hash, display_name FROM users WHERE id = ?') + .get(req.user.id); + + let newHash = undefined; + + if (body.newPassword) { + if (!body.currentPassword) + throw new HttpError(400, 'Mot de passe actuel requis'); + const ok = bcrypt.compareSync(body.currentPassword, current.password_hash); + if (!ok) throw new HttpError(401, 'Mot de passe actuel incorrect'); + newHash = bcrypt.hashSync(body.newPassword, 10); + } + + if (body.email && body.email !== current.email) { + const taken = db.prepare('SELECT id FROM users WHERE email = ? AND id != ?').get(body.email, req.user.id); + if (taken) throw new HttpError(409, 'Email déjà utilisé par un autre compte'); + } + + const newEmail = body.email ?? current.email; + const newDisplayName = body.displayName !== undefined ? body.displayName : current.display_name; + const newPasswordHash = newHash ?? current.password_hash; + + db.prepare( + "UPDATE users SET email=?, display_name=?, password_hash=?, updated_at=datetime('now') WHERE id=?" + ).run(newEmail, newDisplayName, newPasswordHash, req.user.id); + + const token = newEmail !== current.email + ? signToken({ sub: req.user.id, email: newEmail }) + : undefined; + + res.json({ + user: { id: req.user.id, email: newEmail, display_name: newDisplayName }, + ...(token ? { token } : {}), + }); + } catch (e) { next(e); } +}); + +export default router; diff --git a/backend/src/routes/categories-inv.js b/backend/src/routes/categories-inv.js new file mode 100644 index 0000000..3274bce --- /dev/null +++ b/backend/src/routes/categories-inv.js @@ -0,0 +1,102 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import db from '../db/index.js'; +import { HttpError } from '../middleware/errorHandler.js'; + +const router = Router(); +// Monté sous requireAuth dans server.js + +/* ───────────────────────────────────────────────────────────── + GET /api/categories-inv + Retourne les catégories globales (user_id IS NULL) + privées + de l'utilisateur connecté. + Chaque entrée : { id, nom, is_global, nb_plateformes, nb_investissements } +───────────────────────────────────────────────────────────────*/ +router.get('/', (req, res) => { + const userId = req.user.id; + const rows = db.prepare(` + SELECT + c.id, + c.nom, + CASE WHEN c.user_id IS NULL THEN 1 ELSE 0 END AS is_global, + (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 = ? + ) + ) AS nb_plateformes, + (SELECT COUNT(*) FROM investissement_categories_inv ic WHERE ic.categorie_id = c.id + AND ic.investissement_id IN ( + SELECT inv.id FROM investissements inv + JOIN investisseurs i ON i.id = inv.investisseur_id + WHERE i.user_id = ? + ) + ) AS nb_investissements + FROM categories_inv c + WHERE c.user_id IS NULL OR c.user_id = ? + ORDER BY is_global DESC, c.nom + `).all(userId, userId, userId); + res.json(rows); +}); + +/* ───────────────────────────────────────────────────────────── + POST /api/categories-inv — créer une catégorie privée +───────────────────────────────────────────────────────────────*/ +router.post('/', (req, res, next) => { + try { + const { nom } = z.object({ nom: z.string().min(1).max(200) }).parse(req.body); + const userId = req.user.id; + const dup = db.prepare( + 'SELECT id FROM categories_inv WHERE nom = ? AND (user_id IS NULL OR user_id = ?)' + ).get(nom.trim(), userId); + if (dup) throw new HttpError(409, `La catégorie "${nom.trim()}" existe déjà.`); + const r = db.prepare( + 'INSERT INTO categories_inv (nom, user_id) VALUES (?, ?)' + ).run(nom.trim(), userId); + res.status(201).json({ id: r.lastInsertRowid, nom: nom.trim(), is_global: 0, nb_plateformes: 0, nb_investissements: 0 }); + } catch (e) { next(e); } +}); + +/* ───────────────────────────────────────────────────────────── + PUT /api/categories-inv/:id — renommer (uniquement les privées) +───────────────────────────────────────────────────────────────*/ +router.put('/:id', (req, res, next) => { + try { + const { nom } = z.object({ nom: z.string().min(1).max(200) }).parse(req.body); + const userId = req.user.id; + const row = db.prepare('SELECT id, nom, user_id FROM categories_inv WHERE id = ?').get(req.params.id); + if (!row) throw new HttpError(404, 'Catégorie introuvable'); + if (row.user_id === null) throw new HttpError(403, 'Les catégories globales ne peuvent pas être modifiées.'); + if (row.user_id !== userId) throw new HttpError(403, 'Cette catégorie ne vous appartient pas.'); + const dup = db.prepare( + 'SELECT id FROM categories_inv WHERE nom = ? AND (user_id IS NULL OR user_id = ?) AND id != ?' + ).get(nom.trim(), userId, row.id); + if (dup) throw new HttpError(409, `La catégorie "${nom.trim()}" existe déjà.`); + db.prepare('UPDATE categories_inv SET nom = ? WHERE id = ?').run(nom.trim(), row.id); + res.json({ id: row.id, nom: nom.trim(), is_global: 0 }); + } catch (e) { next(e); } +}); + +/* ───────────────────────────────────────────────────────────── + DELETE /api/categories-inv/:id — supprimer (uniquement les privées) +───────────────────────────────────────────────────────────────*/ +router.delete('/:id', (req, res, next) => { + try { + const userId = req.user.id; + const row = db.prepare('SELECT id, user_id FROM categories_inv WHERE id = ?').get(req.params.id); + if (!row) throw new HttpError(404, 'Catégorie introuvable'); + if (row.user_id === null) throw new HttpError(403, 'Les catégories globales ne peuvent pas être supprimées.'); + if (row.user_id !== userId) throw new HttpError(403, 'Cette catégorie ne vous appartient pas.'); + + // Retirer des associations avant suppression + db.transaction(() => { + db.prepare('DELETE FROM plateforme_categories_inv WHERE categorie_id = ?').run(row.id); + db.prepare('DELETE FROM investissement_categories_inv WHERE categorie_id = ?').run(row.id); + db.prepare('DELETE FROM categories_inv WHERE id = ?').run(row.id); + })(); + res.status(204).end(); + } catch (e) { next(e); } +}); + +export default router; diff --git a/backend/src/routes/categories.js b/backend/src/routes/categories.js new file mode 100644 index 0000000..96d63a9 --- /dev/null +++ b/backend/src/routes/categories.js @@ -0,0 +1,65 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import db from '../db/index.js'; +import { HttpError } from '../middleware/errorHandler.js'; + +const router = Router(); + +const DEFAULT_CATEGORIES = [ + 'Crowdfunding immobilier', + 'Dette privée', + 'Immobilier fractionné', + 'P2P Crowdlending', +]; + +/** Insère les catégories par défaut si l'utilisateur n'en a aucune. */ +function seedDefaults(userId) { + const { n } = db + .prepare('SELECT COUNT(*) AS n FROM categories_plateforme WHERE user_id=?') + .get(userId); + if (n === 0) { + const ins = db.prepare( + 'INSERT OR IGNORE INTO categories_plateforme (user_id, nom) VALUES (?,?)' + ); + db.transaction((uid) => { + for (const nom of DEFAULT_CATEGORIES) ins.run(uid, nom); + })(userId); + } +} + +/* GET /api/categories — liste (avec auto-seed au premier appel) */ +router.get('/', (req, res) => { + seedDefaults(req.user.id); + const rows = db + .prepare('SELECT id, nom FROM categories_plateforme WHERE user_id=? ORDER BY nom') + .all(req.user.id); + res.json(rows); +}); + +/* POST /api/categories — créer une catégorie */ +router.post('/', (req, res, next) => { + try { + const { nom } = z.object({ nom: z.string().min(1).max(100) }).parse(req.body); + const r = db + .prepare('INSERT INTO categories_plateforme (user_id, nom) VALUES (?,?)') + .run(req.user.id, nom.trim()); + res.status(201).json({ id: r.lastInsertRowid, nom: nom.trim() }); + } catch (e) { next(e); } +}); + +/* DELETE /api/categories/:id — supprimer (bloqué si utilisée) */ +router.delete('/:id', (req, res, next) => { + try { + const { n } = db + .prepare('SELECT COUNT(*) AS n FROM plateforme_categories WHERE categorie_id=?') + .get(req.params.id); + if (n > 0) throw new HttpError(409, `Catégorie utilisée par ${n} plateforme(s) — retirez-la d'abord.`); + const r = db + .prepare('DELETE FROM categories_plateforme WHERE id=? AND user_id=?') + .run(req.params.id, req.user.id); + if (r.changes === 0) throw new HttpError(404, 'Not found'); + res.status(204).end(); + } catch (e) { next(e); } +}); + +export default router; diff --git a/backend/src/routes/comptes.js b/backend/src/routes/comptes.js new file mode 100644 index 0000000..7e04148 --- /dev/null +++ b/backend/src/routes/comptes.js @@ -0,0 +1,59 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import db from '../db/index.js'; +import { HttpError } from '../middleware/errorHandler.js'; + +const router = Router(); + +const Schema = z.object({ + nom: z.string().min(1), + type: z.enum(['compte_courant', 'pea_pme']).default('compte_courant'), + investisseur_id: z.number().int().positive().nullable().optional(), + banque: z.string().nullable().optional(), + exoneration_fiscale: z.enum(['aucune', 'pfnl_5ans']).default('aucune'), +}); + +router.get('/', (req, res) => { + const rows = db.prepare(` + SELECT c.id, c.nom, c.type, c.banque, c.exoneration_fiscale, c.investisseur_id, c.created_at, + inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom, + inv.type AS investisseur_type, inv.type_fiscal AS investisseur_type_fiscal + FROM comptes c + LEFT JOIN investisseurs inv ON inv.id = c.investisseur_id + WHERE c.user_id = ? + ORDER BY c.nom + `).all(req.user.id); + res.json(rows); +}); + +router.post('/', (req, res, next) => { + try { + const body = Schema.parse(req.body); + const r = db.prepare( + 'INSERT INTO comptes (user_id, nom, type, banque, investisseur_id, exoneration_fiscale) VALUES (?,?,?,?,?,?)' + ).run(req.user.id, body.nom, body.type, body.banque || null, body.investisseur_id ?? null, body.exoneration_fiscale ?? 'aucune'); + res.status(201).json({ id: r.lastInsertRowid, ...body }); + } catch (e) { next(e); } +}); + +router.put('/:id', (req, res, next) => { + try { + const body = Schema.parse(req.body); + const r = db.prepare( + `UPDATE comptes SET nom=?, type=?, banque=?, investisseur_id=?, exoneration_fiscale=?, updated_at=datetime('now') WHERE id=? AND user_id=?` + ).run(body.nom, body.type, body.banque || null, body.investisseur_id ?? null, body.exoneration_fiscale ?? 'aucune', req.params.id, req.user.id); + if (r.changes === 0) throw new HttpError(404, 'Not found'); + res.json({ id: Number(req.params.id), ...body }); + } catch (e) { next(e); } +}); + +router.delete('/:id', (req, res, next) => { + try { + const r = db.prepare('DELETE FROM comptes WHERE id=? AND user_id=?') + .run(req.params.id, req.user.id); + if (r.changes === 0) throw new HttpError(404, 'Not found'); + res.status(204).end(); + } catch (e) { next(e); } +}); + +export default router; diff --git a/backend/src/routes/corrections.js b/backend/src/routes/corrections.js new file mode 100644 index 0000000..e40ceb4 --- /dev/null +++ b/backend/src/routes/corrections.js @@ -0,0 +1,96 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import db from '../db/index.js'; + +const router = Router(); + +const schema = z.object({ + investisseur_id: z.number().int().positive(), + plateforme_id: z.number().int().positive(), + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + montant: z.number(), // positif ou négatif + notes: z.string().optional(), +}); + +/** + * GET /api/corrections + * ?scope=all → tous les investisseurs de l'utilisateur + * ?annee=YYYY → filtre sur l'année + */ +router.get('/', (req, res) => { + const userId = req.user.id; + const scopeAll = req.query.scope === 'all'; + const annee = req.query.annee; + + let where = scopeAll + ? 'c.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)' + : 'c.investisseur_id = ?'; + + const args = [scopeAll ? userId : Number(req.header('X-Investisseur-Id'))]; + + if (annee) { + where += ' AND substr(c.date,1,4) = ?'; + args.push(annee); + } + + const rows = db.prepare(` + SELECT c.id, c.investisseur_id, c.plateforme_id, + c.date, c.montant, c.notes, c.created_at, + p.nom AS plateforme_nom, + p.fiscalite, + inv.nom AS investisseur_nom + FROM corrections_solde c + JOIN plateformes p ON p.id = c.plateforme_id + JOIN investisseurs inv ON inv.id = c.investisseur_id + WHERE ${where} + ORDER BY c.date DESC, c.created_at DESC + `).all(...args); + + res.json(rows); +}); + +/** + * POST /api/corrections + */ +router.post('/', (req, res) => { + const userId = req.user.id; + const data = schema.parse(req.body); + + // Vérifier que la plateforme appartient à l'utilisateur + const plat = db.prepare('SELECT id FROM plateformes WHERE id = ? AND user_id = ?').get(data.plateforme_id, userId); + if (!plat) return res.status(403).json({ error: 'Plateforme inconnue ou non autorisée' }); + + // Vérifier que l'investisseur appartient à l'utilisateur + const inv = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?').get(data.investisseur_id, userId); + if (!inv) return res.status(403).json({ error: 'Investisseur inconnu ou non autorisé' }); + + const stmt = db.prepare(` + INSERT INTO corrections_solde (investisseur_id, plateforme_id, date, montant, notes) + VALUES (?, ?, ?, ?, ?) + `); + const result = stmt.run(data.investisseur_id, data.plateforme_id, data.date, data.montant, data.notes ?? null); + + res.status(201).json({ id: result.lastInsertRowid }); +}); + +/** + * DELETE /api/corrections/:id + */ +router.delete('/:id', (req, res) => { + const userId = req.user.id; + const id = Number(req.params.id); + + // Vérifier que la correction appartient à un investisseur de cet utilisateur + const corr = db.prepare(` + SELECT c.id FROM corrections_solde c + JOIN investisseurs inv ON inv.id = c.investisseur_id + WHERE c.id = ? AND inv.user_id = ? + `).get(id, userId); + + if (!corr) return res.status(404).json({ error: 'Correction introuvable' }); + + db.prepare('DELETE FROM corrections_solde WHERE id = ?').run(id); + res.status(204).end(); +}); + +export default router; diff --git a/backend/src/routes/dashboard.js b/backend/src/routes/dashboard.js new file mode 100644 index 0000000..2960472 --- /dev/null +++ b/backend/src/routes/dashboard.js @@ -0,0 +1,884 @@ +import { Router } from 'express'; +import db from '../db/index.js'; + +const router = Router(); + +/** + * GET /api/dashboard + * + * ?scope=all → agrège tous les investisseurs de l'utilisateur + * (défaut) → filtre sur l'investisseur donné par X-Investisseur-Id + */ +router.get('/', (req, res) => { + const scopeAll = req.query.scope === 'all'; + const userId = req.user.id; + + /* ── Résolution de l'investisseur cible ─────────────────────── */ + let invWhere, invParams, invId; + + if (scopeAll) { + invWhere = 'investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'; + invParams = [userId]; + } else { + const raw = req.header('X-Investisseur-Id'); + invId = Number(raw); + if (!invId) return res.status(400).json({ error: 'Missing investisseur id (header X-Investisseur-Id)' }); + const row = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?').get(invId, userId); + if (!row) return res.status(403).json({ error: 'Investisseur not found or not owned by user' }); + invWhere = 'investisseur_id = ?'; + invParams = [invId]; + } + + /* ── Cash global ─────────────────────────────────────────────── */ + 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 ${invWhere} + `).get(...invParams); + + /* ── Cash par plateforme ─────────────────────────────────────── */ + const cashByPlatform = db.prepare(` + SELECT p.id AS plateforme_id, p.nom, + plat_inv.nom AS detenteur_nom, + COALESCE(SUM(CASE WHEN dr.type='depot' THEN dr.montant END),0) AS depots, + COALESCE(SUM(CASE WHEN dr.type='retrait' THEN dr.montant END),0) AS retraits, + COALESCE(SUM(CASE WHEN dr.type='depot' THEN dr.montant + WHEN dr.type='retrait' THEN -dr.montant END),0) AS solde_net + FROM plateformes p + LEFT JOIN investisseurs plat_inv ON plat_inv.id = p.investisseur_id + LEFT JOIN depots_retraits dr ON dr.plateforme_id = p.id AND ${invWhere.replace('investisseur_id', 'dr.investisseur_id')} + WHERE p.user_id = ? + GROUP BY p.id, p.nom, plat_inv.nom + ORDER BY p.nom + `).all(...invParams, userId); + + /* ── Solde porte-monnaie par plateforme ──────────────────────── */ + const drManuelPerPlat = db.prepare(` + SELECT plateforme_id, + COALESCE(SUM(CASE WHEN type='depot' THEN montant ELSE 0 END), 0) AS depots, + COALESCE(SUM(CASE WHEN type='retrait' AND COALESCE(source,'') != 'auto_remboursement' + THEN montant ELSE 0 END), 0) AS retraits_manuels + FROM depots_retraits + WHERE ${invWhere} + GROUP BY plateforme_id + `).all(...invParams); + + const rembWalletPerPlat = db.prepare(` + SELECT i.plateforme_id, + COALESCE(SUM( + CASE p.fiscalite + WHEN 'flat_tax' THEN r.net_recu + -- Plateformes hors France : pas de PFU prélevé à la source. + -- interets_bruts est déjà net de la retenue locale si applicable. + ELSE r.capital + r.cashback + r.interets_bruts + END + ), 0) AS remb_wallet + FROM remboursements r + JOIN investissements i ON i.id = r.investissement_id + JOIN plateformes p ON p.id = i.plateforme_id + WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')} + AND r.type = 'normal' + AND r.methode_remboursement = 'portefeuille' + GROUP BY i.plateforme_id + `).all(...invParams); + + const bonusWalletPerPlat = db.prepare(` + SELECT bonus_plateforme_id AS plateforme_id, + COALESCE(SUM(cashback), 0) AS bonus_wallet + FROM remboursements + WHERE ${invWhere.replace('investisseur_id', 'bonus_investisseur_id')} + AND type IN ('bonus_parrainage', 'bonus_plateforme') + GROUP BY bonus_plateforme_id + `).all(...invParams); + + const capitalInvestiPerPlat = db.prepare(` + SELECT i.plateforme_id, + COALESCE(SUM( + i.montant_investi + + COALESCE((SELECT SUM(rv.montant) FROM reinvestissements rv WHERE rv.investissement_id = i.id), 0) + ), 0) AS capital_investi + FROM investissements i + WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')} + GROUP BY i.plateforme_id + `).all(...invParams); + + // Corrections de solde (micro-ecarts fiscaux sur plateformes flat_tax) + const correctionsPerPlat = db.prepare(` + SELECT plateforme_id, + COALESCE(SUM(montant), 0) AS total_correction + FROM corrections_solde + WHERE ${invWhere} + GROUP BY plateforme_id + `).all(...invParams); + + // Fusion en une map plateforme_id → solde_portefeuille + const walletMap = {}; + for (const r of drManuelPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) + r.depots - r.retraits_manuels; + for (const r of rembWalletPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) + r.remb_wallet; + for (const r of bonusWalletPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) + r.bonus_wallet; + for (const r of capitalInvestiPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) - r.capital_investi; + for (const r of correctionsPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) + r.total_correction; + + const cashByPlatformEnriched = cashByPlatform.map(p => ({ + ...p, + solde_portefeuille: Math.round((walletMap[p.plateforme_id] || 0) * 100) / 100, + })); + + const solde_portefeuille_total = Math.round( + cashByPlatformEnriched.reduce((s, p) => s + p.solde_portefeuille, 0) * 100 + ) / 100; + + /* ── Portfolio ───────────────────────────────────────────────── */ + const portfolio = db.prepare(` + SELECT + COUNT(*) AS nb_projets, + COALESCE(SUM(i.montant_investi),0) AS total_investi, + COALESCE(SUM(CASE WHEN i.statut='en_cours' 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 encours, + COALESCE(SUM(CASE WHEN i.statut='rembourse' THEN i.montant_investi END),0) AS rembourse, + 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 en_defaut + FROM investissements i WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')} + `).get(...invParams); + + /* ── Interets ────────────────────────────────────────────────── */ + const interets = db.prepare(` + SELECT + COALESCE(SUM(r.interets_bruts),0) AS interets_bruts, + COALESCE(SUM(r.prelev_sociaux),0) AS prelev_sociaux, + COALESCE(SUM(r.prelev_forfaitaire),0) AS prelev_forfaitaire, + COALESCE(SUM(r.net_recu),0) AS net_recu + FROM remboursements r + JOIN investissements i ON i.id = r.investissement_id + WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')} + `).get(...invParams); + + /* ── Interets par annee ──────────────────────────────────────── */ + const interetsParAnnee = scopeAll + ? db.prepare(` + SELECT annee, + SUM(interets_bruts) AS interets_bruts, + SUM(prelev_sociaux) AS prelev_sociaux, + SUM(prelev_forfaitaire) AS prelev_forfaitaire, + SUM(net_recu) AS net_recu + FROM v_interets_annuels + WHERE investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?) + GROUP BY annee ORDER BY annee DESC + `).all(userId) + : db.prepare(` + SELECT * FROM v_interets_annuels WHERE investisseur_id = ? ORDER BY annee DESC + `).all(invId); + + /* ── Prochaines echeances ────────────────────────────────────── */ + const upcoming = db.prepare(` + SELECT s.*, i.nom_projet, p.nom AS plateforme_nom, + plat_inv.nom AS plateforme_detenteur_nom, + (SELECT r.date_remb FROM remboursements r + WHERE r.investissement_id = s.investissement_id + AND r.date_remb = s.date_prevue + LIMIT 1) AS remb_date_exact, + (SELECT r.date_remb FROM remboursements r + WHERE r.investissement_id = s.investissement_id + AND strftime('%Y-%m', r.date_remb) = strftime('%Y-%m', s.date_prevue) + AND r.date_remb != s.date_prevue + LIMIT 1) AS remb_date_mois + FROM simul_remboursements s + JOIN investissements i ON i.id = s.investissement_id + JOIN plateformes p ON p.id = i.plateforme_id + LEFT JOIN investisseurs plat_inv ON plat_inv.id = p.investisseur_id + WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')} + AND s.date_prevue >= date('now','start of month') + AND s.date_prevue < date('now','start of month','+1 month') + ORDER BY s.date_prevue + LIMIT 50 + `).all(...invParams); + + /* ── En retard / procedure ───────────────────────────────────── */ + const enDefaut = db.prepare(` + SELECT i.*, p.nom AS plateforme_nom, + plat_inv.nom AS plateforme_detenteur_nom + FROM investissements i + JOIN plateformes p ON p.id = i.plateforme_id + LEFT JOIN investisseurs plat_inv ON plat_inv.id = p.investisseur_id + WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')} + AND i.statut IN ('en_retard','procedure') + ORDER BY i.date_souscription DESC + `).all(...invParams); + + res.json({ cash, cashByPlatform: cashByPlatformEnriched, solde_portefeuille_total, portfolio, interets, interetsParAnnee, upcoming, enDefaut }); +}); + + +/** + * GET /api/dashboard/solde-historique + * + * Retourne le solde porte-monnaie calculé pour une plateforme à une date donnée. + * Utilise la même formule que solde_portefeuille mais filtrée par date <= :date. + * + * Query params : plateforme_id (requis), date YYYY-MM-DD (requis) + */ +router.get('/solde-historique', (req, res) => { + const { plateforme_id, date } = req.query; + if (!plateforme_id || !date) { + return res.status(400).json({ error: 'plateforme_id et date sont requis' }); + } + + const userId = req.user.id; + const platId = Number(plateforme_id); + + // Vérifier que la plateforme appartient à l'utilisateur + const plat = db.prepare('SELECT id FROM plateformes WHERE id = ? AND user_id = ?').get(platId, userId); + if (!plat) return res.status(403).json({ error: 'Plateforme non trouvée' }); + + /* ── Résolution du scope investisseur (même logique que GET /) ── */ + let invWhere, invParams; + const raw = req.header('X-Investisseur-Id'); + const invId = Number(raw); + + if (invId) { + const row = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?').get(invId, userId); + if (!row) return res.status(403).json({ error: 'Investisseur non trouvé' }); + invWhere = 'investisseur_id = ?'; + invParams = [invId]; + } else { + invWhere = 'investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'; + invParams = [userId]; + } + + /* ── 1. Dépôts − retraits manuels (jusqu'à :date) ─────────────── */ + const dr = db.prepare(` + SELECT + COALESCE(SUM(CASE WHEN type='depot' THEN montant ELSE 0 END), 0) AS depots, + COALESCE(SUM(CASE WHEN type='retrait' + AND COALESCE(source,'') != 'auto_remboursement' + THEN montant ELSE 0 END), 0) AS retraits_manuels + FROM depots_retraits + WHERE plateforme_id = ? AND ${invWhere} AND date_operation <= ? + `).get(platId, ...invParams, date); + + /* ── 2. Remboursements vers porte-monnaie (jusqu'à :date) ──────── */ + const remb = db.prepare(` + SELECT COALESCE(SUM( + CASE p.fiscalite + WHEN 'flat_tax' THEN r.net_recu + ELSE r.capital + r.cashback + r.interets_bruts + END + ), 0) AS remb_wallet + FROM remboursements r + JOIN investissements i ON i.id = r.investissement_id + JOIN plateformes p ON p.id = i.plateforme_id + WHERE i.plateforme_id = ? AND ${invWhere.replace('investisseur_id', 'i.investisseur_id')} + AND r.type = 'normal' AND r.methode_remboursement = 'portefeuille' + AND r.date_remb <= ? + `).get(platId, ...invParams, date); + + /* ── 3. Bonus porte-monnaie (jusqu'à :date) ────────────────────── */ + const bonus = db.prepare(` + SELECT COALESCE(SUM(cashback), 0) AS bonus_wallet + FROM remboursements + WHERE bonus_plateforme_id = ? AND ${invWhere.replace('investisseur_id', 'bonus_investisseur_id')} + AND type IN ('bonus_parrainage', 'bonus_plateforme') + AND date_remb <= ? + `).get(platId, ...invParams, date); + + /* ── 4. Capital investi (souscriptions + réinvestissements jusqu'à :date) ── */ + const capital = db.prepare(` + SELECT COALESCE(SUM( + i.montant_investi + + COALESCE(( + SELECT SUM(rv.montant) + FROM reinvestissements rv + WHERE rv.investissement_id = i.id + AND rv.date_reinvestissement <= ? + ), 0) + ), 0) AS capital_investi + FROM investissements i + WHERE i.plateforme_id = ? AND ${invWhere.replace('investisseur_id', 'i.investisseur_id')} + AND i.date_souscription <= ? + `).get(date, platId, ...invParams, date); + + /* ── 5. Corrections de solde (jusqu'à :date) ───────────────────── */ + const corrections = db.prepare(` + SELECT COALESCE(SUM(montant), 0) AS total_correction + FROM corrections_solde + WHERE plateforme_id = ? AND ${invWhere} AND date <= ? + `).get(platId, ...invParams, date); + + const solde = Math.round(( + dr.depots + - dr.retraits_manuels + + remb.remb_wallet + + bonus.bonus_wallet + - capital.capital_investi + + corrections.total_correction + ) * 100) / 100; + + res.json({ solde }); +}); + + +/** + * GET /api/dashboard/interets-mensuels + * + * Retourne les intérêts réels (remboursements) et projetés (simul_remboursements) + * groupés par mois YYYY-MM pour l'année demandée. + * + * Query params : annee (int, défaut = année courante), scope=all (optionnel) + */ +router.get('/interets-mensuels', (req, res) => { + const annee = parseInt(req.query.annee) || new Date().getFullYear(); + const scopeAll = req.query.scope === 'all'; + const userId = req.user.id; + const anneeStr = String(annee); + + let invWhere, invParams, invId; + + if (scopeAll) { + invWhere = 'investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'; + invParams = [userId]; + } else { + const raw = req.header('X-Investisseur-Id'); + invId = Number(raw); + if (!invId) return res.status(400).json({ error: 'Missing investisseur id (header X-Investisseur-Id)' }); + const row = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?').get(invId, userId); + if (!row) return res.status(403).json({ error: 'Investisseur not found or not owned by user' }); + invWhere = 'investisseur_id = ?'; + invParams = [invId]; + } + + /* ── Intérêts réels groupés par mois ─────────────────────────── */ + // Inclut les remboursements normaux (via investissement) ET les bonus (via bonus_investisseur_id) + let rembourses; + if (scopeAll) { + rembourses = db.prepare(` + SELECT + strftime('%Y-%m', r.date_remb) AS mois, + COALESCE(SUM(r.interets_bruts), 0) AS interets_bruts, + COALESCE(SUM(r.interets_bruts - r.prelev_sociaux - r.prelev_forfaitaire), 0) AS interets_nets, + COALESCE(SUM(r.capital), 0) AS capital, + COALESCE(SUM(r.cashback), 0) AS cashback + FROM remboursements r + LEFT JOIN investissements inv ON inv.id = r.investissement_id + LEFT JOIN investisseurs own ON own.id = inv.investisseur_id + LEFT JOIN investisseurs bonus_own ON bonus_own.id = r.bonus_investisseur_id + WHERE strftime('%Y', r.date_remb) = ? + AND ( + (r.investissement_id IS NOT NULL AND own.user_id = ?) + OR (r.bonus_investisseur_id IS NOT NULL AND bonus_own.user_id = ?) + ) + GROUP BY mois + ORDER BY mois + `).all(anneeStr, userId, userId); + } else { + rembourses = db.prepare(` + SELECT + strftime('%Y-%m', r.date_remb) AS mois, + COALESCE(SUM(r.interets_bruts), 0) AS interets_bruts, + COALESCE(SUM(r.interets_bruts - r.prelev_sociaux - r.prelev_forfaitaire), 0) AS interets_nets, + COALESCE(SUM(r.capital), 0) AS capital, + COALESCE(SUM(r.cashback), 0) AS cashback + FROM remboursements r + LEFT JOIN investissements inv ON inv.id = r.investissement_id + LEFT JOIN investisseurs bonus_own ON bonus_own.id = r.bonus_investisseur_id + WHERE strftime('%Y', r.date_remb) = ? + AND ( + (r.investissement_id IS NOT NULL AND inv.investisseur_id = ?) + OR (r.bonus_investisseur_id IS NOT NULL AND r.bonus_investisseur_id = ?) + ) + GROUP BY mois + ORDER BY mois + `).all(anneeStr, invId, invId); + } + + /* ── Projections groupées par mois ───────────────────────────── */ + // Exclure les investissements qui ont déjà un remboursement réel dans le même mois + // (évite le double-comptage pour le mois en cours) + const projections = db.prepare(` + SELECT + strftime('%Y-%m', s.date_prevue) AS mois, + COALESCE(SUM(s.interets_prevus), 0) AS interets_prevus, + COALESCE(SUM(s.capital_prevu), 0) AS capital_prevu + FROM simul_remboursements s + JOIN investissements i ON i.id = s.investissement_id + WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')} + AND strftime('%Y', s.date_prevue) = ? + AND NOT EXISTS ( + SELECT 1 FROM remboursements r + WHERE r.investissement_id = s.investissement_id + AND strftime('%Y-%m', r.date_remb) = strftime('%Y-%m', s.date_prevue) + ) + GROUP BY mois + ORDER BY mois + `).all(...invParams, anneeStr); + + /* ── Années disponibles (remboursements + projections) ─── */ + const annees = db.prepare(` + SELECT DISTINCT CAST(strftime('%Y', r.date_remb) AS INTEGER) AS annee + FROM remboursements r + JOIN investissements i ON i.id = r.investissement_id + WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')} + UNION + SELECT DISTINCT CAST(strftime('%Y', s.date_prevue) AS INTEGER) AS annee + FROM simul_remboursements s + JOIN investissements i ON i.id = s.investissement_id + WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')} + AND CAST(strftime('%Y', s.date_prevue) AS INTEGER) <= CAST(strftime('%Y', 'now') AS INTEGER) + 10 + ORDER BY annee + `).all(...invParams, ...invParams).map(r => r.annee); + + res.json({ rembourses, projections, annees }); +}); + + +/** + * GET /api/dashboard/interets-annuels + * + * Retourne les intérêts réels et projetés groupés par ANNÉE (toutes années). + * Les projections excluent les échéances déjà passées (>= début du mois courant). + * + * Query params : scope=all (optionnel) + */ +router.get('/interets-annuels', (req, res) => { + const scopeAll = req.query.scope === 'all'; + const userId = req.user.id; + + let invWhere, invParams, invId; + + if (scopeAll) { + invWhere = 'investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'; + invParams = [userId]; + } else { + const raw = req.header('X-Investisseur-Id'); + invId = Number(raw); + if (!invId) return res.status(400).json({ error: 'Missing investisseur id (header X-Investisseur-Id)' }); + const row = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?').get(invId, userId); + if (!row) return res.status(403).json({ error: 'Investisseur not found or not owned by user' }); + invWhere = 'investisseur_id = ?'; + invParams = [invId]; + } + + /* ── Intérêts réels groupés par année ────────────────────────── */ + let rembourses; + if (scopeAll) { + rembourses = db.prepare(` + SELECT + CAST(strftime('%Y', r.date_remb) AS INTEGER) AS annee, + COALESCE(SUM(r.interets_bruts), 0) AS interets_bruts, + COALESCE(SUM(r.interets_bruts - r.prelev_sociaux - r.prelev_forfaitaire), 0) AS interets_nets, + COALESCE(SUM(r.capital), 0) AS capital, + COALESCE(SUM(r.cashback), 0) AS cashback + FROM remboursements r + LEFT JOIN investissements inv ON inv.id = r.investissement_id + LEFT JOIN investisseurs own ON own.id = inv.investisseur_id + LEFT JOIN investisseurs bonus_own ON bonus_own.id = r.bonus_investisseur_id + WHERE ( + (r.investissement_id IS NOT NULL AND own.user_id = ?) + OR (r.bonus_investisseur_id IS NOT NULL AND bonus_own.user_id = ?) + ) + GROUP BY annee + ORDER BY annee + `).all(userId, userId); + } else { + rembourses = db.prepare(` + SELECT + CAST(strftime('%Y', r.date_remb) AS INTEGER) AS annee, + COALESCE(SUM(r.interets_bruts), 0) AS interets_bruts, + COALESCE(SUM(r.interets_bruts - r.prelev_sociaux - r.prelev_forfaitaire), 0) AS interets_nets, + COALESCE(SUM(r.capital), 0) AS capital, + COALESCE(SUM(r.cashback), 0) AS cashback + FROM remboursements r + LEFT JOIN investissements inv ON inv.id = r.investissement_id + LEFT JOIN investisseurs bonus_own ON bonus_own.id = r.bonus_investisseur_id + WHERE ( + (r.investissement_id IS NOT NULL AND inv.investisseur_id = ?) + OR (r.bonus_investisseur_id IS NOT NULL AND r.bonus_investisseur_id = ?) + ) + GROUP BY annee + ORDER BY annee + `).all(invId, invId); + } + + /* ── Projections groupées par année (uniquement futures/en cours) */ + const projections = db.prepare(` + SELECT + CAST(strftime('%Y', s.date_prevue) AS INTEGER) AS annee, + COALESCE(SUM(s.interets_prevus), 0) AS interets_prevus, + COALESCE(SUM(s.capital_prevu), 0) AS capital_prevu + FROM simul_remboursements s + JOIN investissements i ON i.id = s.investissement_id + WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')} + AND s.date_prevue >= date('now', 'start of month') + GROUP BY annee + ORDER BY annee + `).all(...invParams); + + /* ── Années disponibles (remboursements + projections) ───────── */ + const annees = db.prepare(` + SELECT DISTINCT CAST(strftime('%Y', r.date_remb) AS INTEGER) AS annee + FROM remboursements r + JOIN investissements i ON i.id = r.investissement_id + WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')} + UNION + SELECT DISTINCT CAST(strftime('%Y', s.date_prevue) AS INTEGER) AS annee + FROM simul_remboursements s + JOIN investissements i ON i.id = s.investissement_id + WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')} + AND CAST(strftime('%Y', s.date_prevue) AS INTEGER) <= CAST(strftime('%Y', 'now') AS INTEGER) + 10 + ORDER BY annee + `).all(...invParams, ...invParams).map(r => r.annee); + + /* ── Capital souscrit par année (pour KPI "Capital investi" filtré) ── */ + const capitalParAnnee = db.prepare(` + SELECT + CAST(strftime('%Y', i.date_souscription) AS INTEGER) AS annee, + COALESCE(SUM( + i.montant_investi + + COALESCE((SELECT SUM(rv.montant) FROM reinvestissements rv WHERE rv.investissement_id = i.id), 0) + ), 0) AS capital_souscrit + FROM investissements i + WHERE ${invWhere} + GROUP BY annee + ORDER BY annee + `).all(...invParams); + + res.json({ rembourses, projections, annees, capitalParAnnee }); +}); + + + +/** + * GET /api/dashboard/interets-par-plateforme + * + * Retourne les intérêts réels + projetés groupés par plateforme et par mois + * pour l'année demandée, ainsi que le capital investi estimé chaque mois. + * + * Query params : annee (int, défaut = année courante), scope=all (optionnel) + */ +router.get('/interets-par-plateforme', (req, res) => { + const annee = parseInt(req.query.annee) || new Date().getFullYear(); + const scopeAll = req.query.scope === 'all'; + const userId = req.user.id; + const anneeStr = String(annee); + + let invWhere, invParams, invId; + + if (scopeAll) { + invWhere = 'investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'; + invParams = [userId]; + } else { + const raw = req.header('X-Investisseur-Id'); + invId = Number(raw); + if (!invId) return res.status(400).json({ error: 'Missing investisseur id (header X-Investisseur-Id)' }); + const row = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?').get(invId, userId); + if (!row) return res.status(403).json({ error: 'Investisseur not found or not owned by user' }); + invWhere = 'investisseur_id = ?'; + invParams = [invId]; + } + + /* ── Intérêts réels par plateforme et par mois ───────────────── */ + const remboursesByPlat = db.prepare(` + SELECT + p.id AS plateforme_id, + p.nom AS plateforme_nom, + strftime('%Y-%m', r.date_remb) AS mois, + COALESCE(SUM(r.interets_bruts), 0) AS interets_bruts, + COALESCE(SUM(r.interets_bruts - r.prelev_sociaux - r.prelev_forfaitaire), 0) AS interets_nets, + COALESCE(SUM(r.cashback), 0) AS cashback, + COALESCE(SUM(r.capital), 0) AS capital + FROM remboursements r + JOIN investissements i ON i.id = r.investissement_id + JOIN plateformes p ON p.id = i.plateforme_id + WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')} + AND strftime('%Y', r.date_remb) = ? + AND r.type = 'normal' + GROUP BY p.id, p.nom, mois + ORDER BY p.nom, mois + `).all(...invParams, anneeStr); + + /* ── Cashback bonus (parrainage / plateforme) par plateforme et par mois ── */ + const bonusCashbackByPlat = db.prepare(` + SELECT + r.bonus_plateforme_id AS plateforme_id, + p.nom AS plateforme_nom, + strftime('%Y-%m', r.date_remb) AS mois, + COALESCE(SUM(r.cashback), 0) AS cashback + FROM remboursements r + JOIN plateformes p ON p.id = r.bonus_plateforme_id + WHERE ${invWhere.replace('investisseur_id', 'bonus_investisseur_id')} + AND r.type IN ('bonus_parrainage', 'bonus_plateforme') + AND strftime('%Y', r.date_remb) = ? + GROUP BY r.bonus_plateforme_id, p.nom, mois + ORDER BY p.nom, mois + `).all(...invParams, anneeStr); + + /* ── Projections par plateforme et par mois ──────────────────── */ + // Exclure les investissements qui ont déjà un remboursement réel dans le même mois + const projectionsByPlat = db.prepare(` + SELECT + p.id AS plateforme_id, + p.nom AS plateforme_nom, + strftime('%Y-%m', s.date_prevue) AS mois, + COALESCE(SUM(s.interets_prevus), 0) AS interets_prevus, + COALESCE(SUM(s.capital_prevu), 0) AS capital_prevu + FROM simul_remboursements s + JOIN investissements i ON i.id = s.investissement_id + JOIN plateformes p ON p.id = i.plateforme_id + WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')} + AND strftime('%Y', s.date_prevue) = ? + AND NOT EXISTS ( + SELECT 1 FROM remboursements r + WHERE r.investissement_id = s.investissement_id + AND strftime('%Y-%m', r.date_remb) = strftime('%Y-%m', s.date_prevue) + ) + GROUP BY p.id, p.nom, mois + ORDER BY p.nom, mois + `).all(...invParams, anneeStr); + + /* ── Capital investi mensuel ─────────────────────────────────── */ + // Récupérer tous les investissements avec leurs dates pour calculer + // le capital restant dû chaque mois (montant_investi + reinvests − capital_remboursé_cumulé) + const invs = db.prepare(` + SELECT + i.id, + i.montant_investi, + i.date_souscription, + i.date_cible, + i.statut, + (SELECT MAX(r.date_remb) FROM remboursements r WHERE r.investissement_id = i.id) AS last_remb_date + FROM investissements i + WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')} + `).all(...invParams); + + const invIds = invs.map(i => i.id); + const placeholder = invIds.map(() => '?').join(','); + + // Remboursements de capital par investissement avec leur date (tous types normaux) + const capitalRembRows = invIds.length ? db.prepare(` + SELECT investissement_id, date_remb, SUM(capital) AS capital_remb + FROM remboursements + WHERE investissement_id IN (${placeholder}) + AND type = 'normal' + GROUP BY investissement_id, date_remb + `).all(...invIds) : []; + + // Réinvestissements par investissement avec leur date + const reinvestRows = invIds.length ? db.prepare(` + SELECT investissement_id, date_reinvestissement, SUM(montant) AS montant + FROM reinvestissements + WHERE investissement_id IN (${placeholder}) + GROUP BY investissement_id, date_reinvestissement + `).all(...invIds) : []; + + // Capital projeté par investissement (simul_remboursements) — pour les mois futurs + const capitalSimulRows = invIds.length ? db.prepare(` + SELECT investissement_id, date_prevue, SUM(capital_prevu) AS capital_prevu + FROM simul_remboursements + WHERE investissement_id IN (${placeholder}) + GROUP BY investissement_id, date_prevue + `).all(...invIds) : []; + + // Map : inv_id → [{date, capital}] + const capitalRembMap = {}; + for (const r of capitalRembRows) { + if (!capitalRembMap[r.investissement_id]) capitalRembMap[r.investissement_id] = []; + capitalRembMap[r.investissement_id].push({ date: r.date_remb, capital: r.capital_remb }); + } + + // Map : inv_id → [{date, montant}] + const reinvestMap = {}; + for (const r of reinvestRows) { + if (!reinvestMap[r.investissement_id]) reinvestMap[r.investissement_id] = []; + reinvestMap[r.investissement_id].push({ date: r.date_reinvestissement, montant: r.montant }); + } + + // Map : inv_id → [{date, capital}] + const capitalSimulMap = {}; + for (const r of capitalSimulRows) { + if (!capitalSimulMap[r.investissement_id]) capitalSimulMap[r.investissement_id] = []; + capitalSimulMap[r.investissement_id].push({ date: r.date_prevue, capital: r.capital_prevu }); + } + + // Date du jour au format YYYY-MM-DD (locale) pour distinguer passé/futur + const _today = new Date(); + const todayStr = `${_today.getFullYear()}-${String(_today.getMonth() + 1).padStart(2, '0')}-${String(_today.getDate()).padStart(2, '0')}`; + + const capitalMensuel = []; + for (let m = 1; m <= 12; m++) { + const moisStr = `${anneeStr}-${String(m).padStart(2, '0')}`; + const firstDay = `${moisStr}-01`; + // Dernier jour du mois (calcul local pour éviter le décalage UTC) + const _d = new Date(annee, m, 0); + const lastDay = `${_d.getFullYear()}-${String(_d.getMonth() + 1).padStart(2, '0')}-${String(_d.getDate()).padStart(2, '0')}`; + + let capital = 0; + let enDefaut = 0; + for (const inv of invs) { + if (inv.date_souscription > lastDay) continue; // pas encore débuté + + // Capital réellement remboursé jusqu'à la fin de ce mois + const rembs = capitalRembMap[inv.id] ?? []; + const capitalRembCumul = rembs + .filter(r => r.date <= lastDay) + .reduce((s, r) => s + r.capital, 0); + + // Capital projeté pour les échéances futures (après le dernier remb réel et après aujourd'hui) + const lastRembDate = inv.last_remb_date ?? '0000-00-00'; + const capitalSimulCumul = (capitalSimulMap[inv.id] ?? []) + .filter(r => r.date > todayStr && r.date > lastRembDate && r.date <= lastDay) + .reduce((s, r) => s + r.capital, 0); + + const reinvestsCumul = (reinvestMap[inv.id] ?? []) + .filter(r => r.date <= lastDay) + .reduce((s, r) => s + r.montant, 0); + const capitalRestant = Math.max(0, inv.montant_investi + reinvestsCumul - capitalRembCumul - capitalSimulCumul); + + const actif = ['en_cours', 'en_retard', 'procedure'].includes(inv.statut); + if (actif) { + capital += capitalRestant; + if (['en_retard', 'procedure'].includes(inv.statut)) enDefaut += capitalRestant; + } else { + // Remboursé/clôturé : était-il encore actif ce mois-ci ? + // Priorité : date_cible → dernier remboursement réel → exclure + const fin = inv.date_cible ?? inv.last_remb_date ?? null; + if (fin && fin >= firstDay) { + capital += capitalRestant; + } + } + } + capitalMensuel.push({ mois: moisStr, capital: Math.round(capital * 100) / 100, en_defaut: Math.round(enDefaut * 100) / 100 }); + } + + /* ── Noms des détenteurs par plateforme ─────────────────────── */ + const detenteurRows = db.prepare(` + SELECT p.id AS plateforme_id, inv.nom AS detenteur_nom + FROM plateformes p + LEFT JOIN investisseurs inv ON inv.id = p.investisseur_id + WHERE p.user_id = ? + `).all(userId); + const detenteurMap = {}; + for (const d of detenteurRows) detenteurMap[d.plateforme_id] = d.detenteur_nom; + + /* ── Construire la réponse par plateforme ────────────────────── */ + // Indexer les rembs et projections par plateforme_id puis mois + const rembMap = {}; + for (const r of remboursesByPlat) { + if (!rembMap[r.plateforme_id]) rembMap[r.plateforme_id] = { id: r.plateforme_id, nom: r.plateforme_nom, detenteur_nom: detenteurMap[r.plateforme_id] ?? null, rembourses: {}, projections: {} }; + rembMap[r.plateforme_id].rembourses[r.mois] = { interets_bruts: r.interets_bruts, interets_nets: r.interets_nets, cashback: r.cashback, capital: r.capital }; + } + // Ajouter le cashback bonus (parrainage/plateforme) aux remboursements par mois + for (const b of bonusCashbackByPlat) { + if (!rembMap[b.plateforme_id]) rembMap[b.plateforme_id] = { id: b.plateforme_id, nom: b.plateforme_nom, detenteur_nom: detenteurMap[b.plateforme_id] ?? null, rembourses: {}, projections: {} }; + const entry = rembMap[b.plateforme_id].rembourses[b.mois]; + if (entry) { + entry.cashback = (entry.cashback ?? 0) + b.cashback; + } else { + rembMap[b.plateforme_id].rembourses[b.mois] = { interets_bruts: 0, interets_nets: 0, cashback: b.cashback, capital: 0 }; + } + } + + for (const p of projectionsByPlat) { + if (!rembMap[p.plateforme_id]) rembMap[p.plateforme_id] = { id: p.plateforme_id, nom: p.plateforme_nom, detenteur_nom: detenteurMap[p.plateforme_id] ?? null, rembourses: {}, projections: {} }; + rembMap[p.plateforme_id].projections[p.mois] = { interets_prevus: p.interets_prevus, capital_prevu: p.capital_prevu }; + } + + const plateformes = Object.values(rembMap).sort((a, b) => a.nom.localeCompare(b.nom)); + + res.json({ annee, plateformes, capitalMensuel }); +}); + + +/** + * GET /api/dashboard/detail-cellule + * + * Retourne le détail ligne par ligne d'une cellule du tableau mensuel : + * - recus : remboursements réels pour plateforme_id + YYYY-MM + * - projetes : simul_remboursements pour plateforme_id + YYYY-MM + * (uniquement les projections sans remboursement réel dans ce mois) + * + * Query params : plateforme_id (int), annee (4 chiffres), mois (01-12), scope=all (opt) + */ +router.get('/detail-cellule', (req, res) => { + const { plateforme_id, annee, mois } = req.query; + const scopeAll = req.query.scope === 'all'; + const userId = req.user.id; + + if (!annee || !mois) + return res.status(400).json({ error: 'annee et mois sont requis' }); + + const moisStr = `${annee}-${String(mois).padStart(2, '0')}`; + const platId = plateforme_id ? Number(plateforme_id) : null; + const platFilter = platId ? 'AND i.plateforme_id = ?' : ''; + + let invWhere, invParams; + if (scopeAll) { + invWhere = 'i.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'; + invParams = [userId]; + } else { + const raw = req.header('X-Investisseur-Id'); + const invId = Number(raw); + if (!invId) return res.status(400).json({ error: 'Missing X-Investisseur-Id' }); + const row = db.prepare('SELECT id FROM investisseurs WHERE id=? AND user_id=?').get(invId, userId); + if (!row) return res.status(403).json({ error: 'Investisseur not owned by user' }); + invWhere = 'i.investisseur_id = ?'; + invParams = [invId]; + } + + const recusParams = platId ? [...invParams, platId, moisStr] : [...invParams, moisStr]; + const projetesParams = platId ? [...invParams, platId, moisStr, moisStr] : [...invParams, moisStr, moisStr]; + + /* ── Remboursements reçus ─────────────────────────────────── */ + const recus = db.prepare(` + SELECT + r.id, r.investissement_id, r.date_remb, + r.capital, r.cashback, + r.interets_bruts, r.prelev_sociaux, r.prelev_forfaitaire, + r.interets_nets, r.net_recu, + i.nom_projet, i.plateforme_id, + p.nom AS plateforme_nom, + inv.nom AS detenteur_nom + FROM remboursements r + JOIN investissements i ON i.id = r.investissement_id + JOIN investisseurs inv ON inv.id = i.investisseur_id + LEFT JOIN plateformes p ON p.id = i.plateforme_id + WHERE ${invWhere} + ${platFilter} + AND strftime('%Y-%m', r.date_remb) = ? + AND r.type = 'normal' + ORDER BY p.nom, r.date_remb, i.nom_projet + `).all(...recusParams); + + /* ── Projections (sans remb réel dans le même mois) ─────── */ + const projetes = db.prepare(` + SELECT + s.id, s.investissement_id, s.date_prevue, + s.capital_prevu, s.interets_prevus, s.total_prevu, + s.numero_echeance, + i.nom_projet, i.plateforme_id, + p.nom AS plateforme_nom, + inv.nom AS detenteur_nom + FROM simul_remboursements s + JOIN investissements i ON i.id = s.investissement_id + JOIN investisseurs inv ON inv.id = i.investisseur_id + LEFT JOIN plateformes p ON p.id = i.plateforme_id + WHERE ${invWhere} + ${platFilter} + AND strftime('%Y-%m', s.date_prevue) = ? + AND NOT EXISTS ( + SELECT 1 FROM remboursements r + WHERE r.investissement_id = s.investissement_id + AND strftime('%Y-%m', r.date_remb) = ? + ) + ORDER BY p.nom, s.date_prevue, i.nom_projet + `).all(...projetesParams); + + res.json({ recus, projetes }); +}); + +export default router; diff --git a/backend/src/routes/depotsRetraits.js b/backend/src/routes/depotsRetraits.js new file mode 100644 index 0000000..6f4b32c --- /dev/null +++ b/backend/src/routes/depotsRetraits.js @@ -0,0 +1,118 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import db from '../db/index.js'; +import { HttpError } from '../middleware/errorHandler.js'; +import { requireInvestisseur } from '../middleware/investisseurScope.js'; + +const router = Router(); + +const Schema = z.object({ + investisseur_id: z.number().int().positive().optional(), + plateforme_id: z.number().int().positive(), + date_operation: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + type: z.enum(['depot', 'retrait']), + montant: z.number().nonnegative(), + libelle: z.string().optional(), + reference: z.string().optional(), + notes: z.string().optional(), +}); + +/** Résout l'investisseur_id : body en priorité (validé), sinon header */ +function resolveInvestisseurId(req, bodyInvestisseurId) { + if (!bodyInvestisseurId) return req.investisseur.id; + const row = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?') + .get(bodyInvestisseurId, req.user.id); + if (!row) throw new HttpError(403, 'Investisseur non autorisé'); + return bodyInvestisseurId; +} + +/** + * GET /api/depots-retraits + * + * ?scope=all → agrège tous les investisseurs de l'utilisateur (vue "Famille") + * (défaut) → filtre sur l'investisseur donné par X-Investisseur-Id + */ +router.get('/', (req, res) => { + const scopeAll = req.query.scope === 'all'; + const userId = req.user.id; + + let invCond, args; + if (scopeAll) { + invCond = 'dr.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'; + args = [userId]; + } else { + const raw = req.header('X-Investisseur-Id'); + const id = Number(raw); + if (!id) return res.status(400).json({ error: 'Missing investisseur id (header X-Investisseur-Id)' }); + const row = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?').get(id, userId); + if (!row) return res.status(403).json({ error: 'Investisseur not found or not owned by user' }); + invCond = 'dr.investisseur_id = ?'; + args = [id]; + } + + const { from, to, type, plateforme_id } = req.query; + const conds = [invCond]; + if (from) { conds.push('dr.date_operation >= ?'); args.push(from); } + if (to) { conds.push('dr.date_operation <= ?'); args.push(to); } + if (type) { conds.push('dr.type = ?'); args.push(type); } + if (plateforme_id) { conds.push('dr.plateforme_id = ?'); args.push(Number(plateforme_id)); } + + const rows = db.prepare(` + SELECT dr.*, p.nom AS plateforme_nom, + plat_inv.nom AS plateforme_detenteur_nom + FROM depots_retraits dr + JOIN plateformes p ON p.id = dr.plateforme_id + LEFT JOIN investisseurs plat_inv ON plat_inv.id = p.investisseur_id + WHERE ${conds.join(' AND ')} + ORDER BY dr.date_operation DESC, dr.id DESC + `).all(...args); + res.json(rows); +}); + +router.post('/', requireInvestisseur, (req, res, next) => { + try { + const body = Schema.parse(req.body); + const investisseurId = resolveInvestisseurId(req, body.investisseur_id); + const r = db.prepare(` + INSERT INTO depots_retraits + (investisseur_id, plateforme_id, date_operation, type, montant, libelle, reference, source, notes) + VALUES (?,?,?,?,?,?,?, 'manuel', ?) + `).run( + investisseurId, body.plateforme_id, body.date_operation, body.type, + body.montant, body.libelle || null, body.reference || null, body.notes || null, + ); + res.status(201).json({ id: r.lastInsertRowid, ...body }); + } catch (e) { next(e); } +}); + +router.put('/:id', requireInvestisseur, (req, res, next) => { + try { + const body = Schema.parse(req.body); + const investisseurId = resolveInvestisseurId(req, body.investisseur_id); + const r = db.prepare(` + UPDATE depots_retraits + SET investisseur_id=?, plateforme_id=?, date_operation=?, type=?, montant=?, + libelle=?, reference=?, notes=?, updated_at=datetime('now') + WHERE id=? AND investisseur_id IN (SELECT id FROM investisseurs WHERE user_id=?) + `).run( + investisseurId, body.plateforme_id, body.date_operation, body.type, body.montant, + body.libelle || null, body.reference || null, body.notes || null, + req.params.id, req.user.id, + ); + if (r.changes === 0) throw new HttpError(404, 'Not found'); + res.json({ id: Number(req.params.id), ...body }); + } catch (e) { next(e); } +}); + +router.delete('/:id', requireInvestisseur, (req, res, next) => { + try { + const r = db.prepare(` + DELETE FROM depots_retraits + WHERE id=? AND investisseur_id IN (SELECT id FROM investisseurs WHERE user_id=?) + `).run(req.params.id, req.user.id); + if (r.changes === 0) throw new HttpError(404, 'Not found'); + res.status(204).end(); + } catch (e) { next(e); } +}); + +export default router; diff --git a/backend/src/routes/garanties.js b/backend/src/routes/garanties.js new file mode 100644 index 0000000..ddff71e --- /dev/null +++ b/backend/src/routes/garanties.js @@ -0,0 +1,135 @@ +import { Router } from 'express'; +import multer from 'multer'; +import xlsx from 'xlsx'; +import path from 'node:path'; +import fs from 'node:fs'; +import db from '../db/index.js'; +import { HttpError } from '../middleware/errorHandler.js'; + +const UPLOAD_DIR = process.env.UPLOAD_DIR || path.resolve('./uploads'); +fs.mkdirSync(UPLOAD_DIR, { recursive: true }); +const upload = multer({ dest: UPLOAD_DIR, limits: { fileSize: 5 * 1024 * 1024 } }); + +const router = Router(); + +/* ── GET /api/garanties ──────────────────────────────────────── */ +router.get('/', (req, res) => { + const rows = db.prepare( + 'SELECT * FROM garantie_types WHERE user_id = ? ORDER BY ordre, id' + ).all(req.user.id); + res.json(rows); +}); + +/* ── POST /api/garanties ─────────────────────────────────────── */ +router.post('/', (req, res, next) => { + try { + const { libelle, description, ordre } = req.body || {}; + if (!libelle?.trim()) throw new HttpError(400, 'libelle est requis'); + const r = db.prepare( + 'INSERT INTO garantie_types (user_id, libelle, description, ordre) VALUES (?,?,?,?)' + ).run(req.user.id, libelle.trim(), description?.trim() || null, Number(ordre ?? 0)); + res.status(201).json(db.prepare('SELECT * FROM garantie_types WHERE id = ?').get(r.lastInsertRowid)); + } catch (e) { next(e); } +}); + +/* ── PUT /api/garanties/:id ──────────────────────────────────── */ +router.put('/:id', (req, res, next) => { + try { + const row = db.prepare('SELECT id FROM garantie_types WHERE id = ? AND user_id = ?') + .get(req.params.id, req.user.id); + if (!row) throw new HttpError(404, 'Garantie introuvable'); + const { libelle, description, ordre } = req.body || {}; + if (!libelle?.trim()) throw new HttpError(400, 'libelle est requis'); + db.prepare( + 'UPDATE garantie_types SET libelle=?, description=?, ordre=? WHERE id=?' + ).run(libelle.trim(), description?.trim() || null, Number(ordre ?? 0), req.params.id); + res.json(db.prepare('SELECT * FROM garantie_types WHERE id = ?').get(req.params.id)); + } catch (e) { next(e); } +}); + +/* ── DELETE /api/garanties/:id ───────────────────────────────── */ +router.delete('/:id', (req, res, next) => { + try { + const row = db.prepare('SELECT id FROM garantie_types WHERE id = ? AND user_id = ?') + .get(req.params.id, req.user.id); + if (!row) throw new HttpError(404, 'Garantie introuvable'); + db.prepare('DELETE FROM garantie_types WHERE id = ?').run(req.params.id); + res.json({ deleted: true }); + } catch (e) { next(e); } +}); + +/* ── POST /api/garanties/import ───────────────────────────────── + Accepte un fichier .xlsx / .csv / .json + Colonnes reconnues : libelle (obligatoire), description, ordre + Comportement : upsert sur libelle (insensible à la casse) + ─────────────────────────────────────────────────────────────── */ +router.post('/import', upload.single('file'), (req, res, next) => { + try { + if (!req.file) throw new HttpError(400, 'Aucun fichier reçu'); + + const ext = path.extname(req.file.originalname).toLowerCase(); + let rows; + + if (ext === '.json') { + const content = fs.readFileSync(req.file.path, 'utf8'); + let parsed; + try { parsed = JSON.parse(content); } catch { + throw new HttpError(400, 'Fichier JSON invalide'); + } + rows = Array.isArray(parsed) ? parsed + : Array.isArray(parsed?.garanties) ? parsed.garanties + : null; + if (!rows) throw new HttpError(400, 'Le JSON doit être un tableau ou contenir une clé "garanties"'); + } else { + const wb = xlsx.readFile(req.file.path, { cellDates: true }); + const ws = wb.Sheets[wb.SheetNames[0]]; + rows = xlsx.utils.sheet_to_json(ws, { defval: null, raw: false }); + } + + try { fs.unlinkSync(req.file.path); } catch { /* noop */ } + + if (!rows.length) return res.json({ inserted: 0, updated: 0, skipped: 0, errors: [] }); + + // Normalise les clés (insensible à la casse et aux espaces) + const norm = (obj) => { + const out = {}; + for (const [k, v] of Object.entries(obj)) out[k.toLowerCase().trim()] = v; + return out; + }; + + let inserted = 0, updated = 0, skipped = 0; + const errors = []; + + const tx = db.transaction(() => { + for (let i = 0; i < rows.length; i++) { + const r = norm(rows[i]); + const libelle = String(r.libelle ?? r['libellé'] ?? r.label ?? '').trim(); + if (!libelle) { skipped++; errors.push({ row: i + 2, error: 'libelle vide — ligne ignorée' }); continue; } + + const description = String(r.description ?? r.desc ?? '').trim() || null; + const ordre = parseInt(r.ordre ?? r.order ?? 0, 10) || 0; + + const existing = db.prepare( + 'SELECT id FROM garantie_types WHERE user_id = ? AND LOWER(libelle) = LOWER(?)' + ).get(req.user.id, libelle); + + if (existing) { + db.prepare( + 'UPDATE garantie_types SET description=?, ordre=? WHERE id=?' + ).run(description, ordre, existing.id); + updated++; + } else { + db.prepare( + 'INSERT INTO garantie_types (user_id, libelle, description, ordre) VALUES (?,?,?,?)' + ).run(req.user.id, libelle, description, ordre); + inserted++; + } + } + }); + tx(); + + res.json({ inserted, updated, skipped, total: rows.length, errors: errors.slice(0, 20) }); + } catch (e) { next(e); } +}); + +export default router; diff --git a/backend/src/routes/icons.js b/backend/src/routes/icons.js new file mode 100644 index 0000000..7a4a3f6 --- /dev/null +++ b/backend/src/routes/icons.js @@ -0,0 +1,315 @@ +import { Router } from 'express'; +import multer from 'multer'; +import path from 'path'; +import fs from 'fs'; +import { fileURLToPath } from 'url'; +import db from '../db/index.js'; +import { requireAdmin } from '../middleware/auth.js'; +import sharp from 'sharp'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const iconsDir = path.resolve(__dirname, '../../../data/icons'); +const historyDir = path.resolve(iconsDir, 'history'); +fs.mkdirSync(iconsDir, { recursive: true }); +fs.mkdirSync(historyDir, { recursive: true }); + + +// ── Suppression fond blanc SVG ──────────────────────────────────────────────── + +function isNearWhite(color) { + if (!color) return false; + const c = color.trim().toLowerCase(); + if (c === 'white' || c === 'snow') return true; + const s3 = c.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/); + if (s3) { + return parseInt(s3[1] + s3[1], 16) >= 240 && + parseInt(s3[2] + s3[2], 16) >= 240 && + parseInt(s3[3] + s3[3], 16) >= 240; + } + const s6 = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/); + if (s6) { + return parseInt(s6[1], 16) >= 240 && + parseInt(s6[2], 16) >= 240 && + parseInt(s6[3], 16) >= 240; + } + const rgb = c.match(/^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/); + if (rgb) { + return parseInt(rgb[1]) >= 240 && parseInt(rgb[2]) >= 240 && parseInt(rgb[3]) >= 240; + } + return false; +} + +function getFillFromElement(str) { + const fa = str.match(/\bfill\s*=\s*["']([^"']*)["']/i); + if (fa) return fa[1]; + const sa = str.match(/\bstyle\s*=\s*["']([^"']*)["']/i); + if (sa) { + const fm = sa[1].match(/(?:^|;)\s*fill\s*:\s*([^;]+)/i); + if (fm) return fm[1].trim(); + } + return null; +} + +function pathCoversCanvas(d, vbW, vbH) { + if (!vbW || !vbH) return true; + const nums = (d.match(/[\d.]+/g) || []).map(parseFloat); + const hasNearZero = nums.some(v => v <= 5); + const hasMaxX = nums.some(v => Math.abs(v - vbW) <= 5); + const hasMaxY = nums.some(v => Math.abs(v - vbH) <= 5); + return hasNearZero && hasMaxX && hasMaxY; +} + +function stripSvgBackground(svgContent) { + let out = svgContent; + + // 1. Remove background-color from style="" on + out = out.replace( + /(]*)\sstyle="([^"]*)"/i, + (_, tag, style) => { + const cleaned = style.split(';') + .filter(s => !/^\s*background(-color)?\s*:/i.test(s)) + .join(';').replace(/^;+|;+$/g, ''); + return cleaned ? `${tag} style="${cleaned}"` : tag; + } + ); + + // 2. Remove enable-background (Adobe Illustrator artifact) + out = out.replace(/\s+enable-background="[^"]*"/gi, ''); + + // 3. Parse viewBox for canvas coverage check + const vbMatch = out.match(/\bviewBox\s*=\s*["']\s*[\d.]+\s+[\d.]+\s+([\d.]+)\s+([\d.]+)\s*["']/i); + const vbW = vbMatch ? parseFloat(vbMatch[1]) : 0; + const vbH = vbMatch ? parseFloat(vbMatch[2]) : 0; + + // 4. Remove near-white elements at origin covering the canvas + out = out.replace(/]*)?\/?>/gis, (match) => { + const fill = getFillFromElement(match); + if (!fill || !isNearWhite(fill)) return match; + const x = match.match(/\bx\s*=\s*["']?([^"'\s>]+)/i); + const y = match.match(/\by\s*=\s*["']?([^"'\s>]+)/i); + if ((x && parseFloat(x[1]) > 5) || (y && parseFloat(y[1]) > 5)) return match; + const w = match.match(/\bwidth\s*=\s*["']?([^"'\s>]+)/i); + const h = match.match(/\bheight\s*=\s*["']?([^"'\s>]+)/i); + if (!w || !h) return match; + return ''; + }); + out = out.replace(/<\/rect>/gi, ''); + + // 5. Remove near-white elements covering the canvas + // (raster-trace backgrounds from design tools like GIMP/Inkscape export) + out = out.replace(/]*\/?>/gis, (match) => { + const fill = getFillFromElement(match); + if (!fill || !isNearWhite(fill)) return match; + const dAttr = match.match(/\bd\s*=\s*["']([^"']*?)["']/is); + if (!dAttr) return match; + if (pathCoversCanvas(dAttr[1], vbW, vbH)) return ''; + return match; + }); + out = out.replace(/<\/path>/gi, ''); + + out = out.replace(/\n{3,}/g, '\n\n'); + return out; +} + +// ── Retraitement post-upload ─────────────────────────────────────────────────── +// Retourne le chemin final du fichier (peut changer si JPG/WebP converti en PNG) +async function processUploadedFile(filePath) { + const ext = path.extname(filePath).toLowerCase(); + + // ── SVG : suppression fond blanc en pur texte ────────────────── + if (ext === '.svg') { + try { + const original = fs.readFileSync(filePath, 'utf8'); + const cleaned = stripSvgBackground(original); + if (cleaned !== original) fs.writeFileSync(filePath, cleaned, 'utf8'); + } catch (e) { + console.warn('[icons] stripSvgBackground failed:', e.message); + } + return filePath; + } + + // ── Raster (PNG / JPG / WebP) : fond blanc → transparent via sharp ── + if (['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) { + // La transparence nécessite PNG — on convertit si besoin + const pngPath = filePath.replace(/\.(jpg|jpeg|webp|png)$/i, '.png'); + try { + const { data, info } = await sharp(filePath) + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + + // Rendre transparents les pixels blanc ou quasi-blanc (seuil > 240/255) + for (let i = 0; i < data.length; i += 4) { + if (data[i] > 240 && data[i + 1] > 240 && data[i + 2] > 240) { + data[i + 3] = 0; + } + } + + await sharp(data, { + raw: { width: info.width, height: info.height, channels: 4 }, + }).png({ compressionLevel: 8 }).toFile(pngPath); + + // Supprimer l'original s'il a changé d'extension + if (pngPath !== filePath) fs.unlinkSync(filePath); + + return pngPath; + } catch (e) { + console.warn('[icons] sharp processing failed:', e.message); + return filePath; // garder le fichier original en cas d'erreur + } + } + + return filePath; +} + +const router = Router(); + +// ── Multer ──────────────────────────────────────────────────────────────────── +const storage = multer.diskStorage({ + destination: (_req, _file, cb) => cb(null, iconsDir), + filename: (req, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase() || '.svg'; + const name = req.params.name || req.body?.name || 'icon'; + cb(null, `icon_${name}_${Date.now()}${ext}`); + }, +}); +const upload = multer({ + storage, + limits: { fileSize: 2 * 1024 * 1024 }, // 2 Mo max + fileFilter: (_req, file, cb) => { + const allowed = ['.svg', '.png', '.jpg', '.jpeg', '.webp']; + if (allowed.includes(path.extname(file.originalname).toLowerCase())) cb(null, true); + else cb(new Error('Format non supporté — SVG, PNG, JPG ou WebP uniquement')); + }, +}); + +// ── Validation slug ─────────────────────────────────────────────────────────── +function isValidSlug(s) { + return /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(s) && s.length <= 64; +} + +// ── GET /api/icons — liste toutes les icônes (authentifié) ─────────────────── +router.get('/', (req, res) => { + const rows = db.prepare(` + SELECT id, name, filename, description, created_at, updated_at + FROM app_icons + ORDER BY name + `).all(); + res.json(rows); +}); + +// ── GET /api/icons/:name — détail d'une icône ──────────────────────────────── +router.get('/:name', (req, res) => { + const icon = db.prepare('SELECT * FROM app_icons WHERE name = ?').get(req.params.name); + if (!icon) return res.status(404).json({ error: 'Icône introuvable' }); + res.json(icon); +}); + +// ── GET /api/icons/:name/history — historique des versions ────────────────── +router.get('/:name/history', requireAdmin, (req, res) => { + const icon = db.prepare('SELECT id FROM app_icons WHERE name = ?').get(req.params.name); + if (!icon) return res.status(404).json({ error: 'Icône introuvable' }); + + const rows = db.prepare(` + SELECT id, filename, replaced_at + FROM app_icons_history + WHERE icon_id = ? + ORDER BY replaced_at DESC + `).all(icon.id); + res.json(rows); +}); + +// ── POST /api/icons — créer une nouvelle association nom/image ─────────────── +router.post('/', requireAdmin, upload.single('file'), async (req, res, next) => { + try { + const name = (req.body.name || '').trim().toLowerCase(); + const description = (req.body.description || '').trim() || null; + + if (!name) return res.status(400).json({ error: 'Le nom est requis' }); + if (!isValidSlug(name)) return res.status(400).json({ error: 'Nom invalide — lettres minuscules, chiffres et tirets uniquement' }); + if (!req.file) return res.status(400).json({ error: 'Fichier requis' }); + + // Renommer le fichier avec le bon nom maintenant qu'on a le slug + const ext = path.extname(req.file.originalname).toLowerCase() || '.svg'; + const newFilename = `icon_${name}_${Date.now()}${ext}`; + fs.renameSync(req.file.path, path.join(iconsDir, newFilename)); + const finalPath = await processUploadedFile(path.join(iconsDir, newFilename)); + const finalFilename = path.basename(finalPath); + + const row = db.prepare(` + INSERT INTO app_icons (name, filename, description) + VALUES (?, ?, ?) + RETURNING * + `).get(name, finalFilename, description); + + res.status(201).json(row); + } catch (err) { + if (err.code === 'SQLITE_CONSTRAINT_UNIQUE') { + if (req.file) fs.unlinkSync(req.file.path).catch?.(() => {}); + return res.status(409).json({ error: `Le nom "${req.body?.name}" existe déjà` }); + } + next(err); + } +}); + +// ── PUT /api/icons/:name — remplacer l'image (archive l'ancienne) ──────────── +router.put('/:name', requireAdmin, upload.single('file'), async (req, res, next) => { + try { + const icon = db.prepare('SELECT * FROM app_icons WHERE name = ?').get(req.params.name); + if (!icon) return res.status(404).json({ error: 'Icône introuvable' }); + if (!req.file) return res.status(400).json({ error: 'Fichier requis' }); + + const ext = path.extname(req.file.originalname).toLowerCase() || '.svg'; + const newFilename = `icon_${icon.name}_${Date.now()}${ext}`; + fs.renameSync(req.file.path, path.join(iconsDir, newFilename)); + const finalPath = await processUploadedFile(path.join(iconsDir, newFilename)); + const finalFilename = path.basename(finalPath); + + const doReplace = db.transaction(() => { + // Archiver l'ancienne version + db.prepare(` + INSERT INTO app_icons_history (icon_id, filename, replaced_at) + VALUES (?, ?, datetime('now')) + `).run(icon.id, icon.filename); + + // Mettre à jour l'entrée principale + return db.prepare(` + UPDATE app_icons SET filename = ?, updated_at = datetime('now') + WHERE id = ? + RETURNING * + `).get(finalFilename, icon.id); + }); + + const updated = doReplace(); + + // Supprimer les fichiers d'historique au-delà de 10 versions + const old = db.prepare(` + SELECT id, filename FROM app_icons_history + WHERE icon_id = ? + ORDER BY replaced_at DESC + LIMIT -1 OFFSET 10 + `).all(icon.id); + for (const o of old) { + fs.unlink(path.join(iconsDir, o.filename), () => {}); + db.prepare('DELETE FROM app_icons_history WHERE id = ?').run(o.id); + } + + res.json(updated); + } catch (err) { + next(err); + } +}); + +// ── PATCH /api/icons/:name — modifier description uniquement ───────────────── +router.patch('/:name', requireAdmin, (req, res) => { + const { description } = req.body; + const updated = db.prepare(` + UPDATE app_icons SET description = ?, updated_at = datetime('now') + WHERE name = ? + RETURNING * + `).get(description ?? null, req.params.name); + if (!updated) return res.status(404).json({ error: 'Icône introuvable' }); + res.json(updated); +}); + +export default router; diff --git a/backend/src/routes/imports.js b/backend/src/routes/imports.js new file mode 100644 index 0000000..c9ed86b --- /dev/null +++ b/backend/src/routes/imports.js @@ -0,0 +1,531 @@ +import { Router } from 'express'; +import multer from 'multer'; +import xlsx from 'xlsx'; +import path from 'node:path'; +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'; + +const router = Router(); + +const UPLOAD_DIR = process.env.UPLOAD_DIR || path.resolve('./uploads'); +fs.mkdirSync(UPLOAD_DIR, { recursive: true }); + +const upload = multer({ + dest: UPLOAD_DIR, + limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB +}); + +/** + * Step 1: POST /api/imports/preview + * multipart/form-data with `file` (xlsx/csv) + * -> returns: { headers: [...], sampleRows: [...], allRowCount, tempId } + * + * Step 2: POST /api/imports/apply + * body: { tempId, module, mapping, defaults } + * -> applies the mapping and inserts rows in the chosen module + */ + +// Modules scoped to a specific investisseur (require X-Investisseur-Id) +const INVESTISSEUR_SCOPED = ['depots_retraits', 'investissements', 'remboursements']; + +const MODULES = { + depots_retraits: { + requiredTargets: ['plateforme_id', 'date_operation', 'type', 'montant'], + optionalTargets: ['libelle', 'reference', 'notes'], + }, + investissements: { + requiredTargets: ['plateforme_id', 'nom_projet', 'date_souscription', 'montant_investi'], + optionalTargets: ['emetteur','date_premiere_echeance','date_cible','taux_interet','duree_mois','type_remb','freq_interets','statut','reference','notes'], + }, + remboursements: { + requiredTargets: ['investissement_id', 'date_remb'], + optionalTargets: ['capital','cashback','interets_bruts','prelev_sociaux','prelev_forfaitaire','statut','notes'], + }, + plateformes: { + requiredTargets: ['nom'], + optionalTargets: ['url', 'notes'], + }, + taux_pfu: { + requiredTargets: ['annee', 'pfu_total', 'impot_revenu', 'prelev_sociaux'], + optionalTargets: [], + }, +}; + +/** Parse un fichier uploadé selon son extension → tableau d'objets */ +function parseFile(filePath, originalName) { + const ext = path.extname(originalName).toLowerCase(); + if (ext === '.json') { + const content = fs.readFileSync(filePath, 'utf8'); + let parsed; + try { parsed = JSON.parse(content); } catch { + throw new HttpError(400, 'Fichier JSON invalide — vérifiez la syntaxe'); + } + if (!Array.isArray(parsed)) { + throw new HttpError(400, 'Le fichier JSON doit contenir un tableau d\'objets à la racine'); + } + return { rows: parsed, sheetName: 'json' }; + } + // Excel / CSV + const wb = xlsx.readFile(filePath, { cellDates: true }); + const sheetName = wb.SheetNames[0]; + const ws = wb.Sheets[sheetName]; + return { rows: xlsx.utils.sheet_to_json(ws, { defval: null, raw: false }), sheetName }; +} + +router.post('/preview', upload.single('file'), (req, res, next) => { + try { + if (!req.file) throw new HttpError(400, 'No file uploaded'); + const { rows, sheetName } = parseFile(req.file.path, req.file.originalname); + const headers = rows.length ? Object.keys(rows[0]) : []; + + res.json({ + tempId: path.basename(req.file.path), + filename: req.file.originalname, + sheetName, + headers, + sampleRows: rows.slice(0, 10), + allRowCount: rows.length, + }); + } catch (e) { next(e); } +}); + +router.post('/apply', (req, res, next) => { + try { + const { tempId, module, mapping, defaults = {} } = 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, () => {}); + } + + const tempPath = path.join(UPLOAD_DIR, tempId); + if (!fs.existsSync(tempPath)) throw new HttpError(404, 'Uploaded file expired'); + + for (const target of def.requiredTargets) { + if (!mapping[target] && defaults[target] === undefined) { + throw new HttpError(400, `Missing mapping for required field: ${target}`); + } + } + + // Déterminer la source selon l'extension du fichier original (passé en body optionnel) + const origName = req.body.originalFilename || ''; + const isJson = path.extname(origName).toLowerCase() === '.json'; + const srcLabel = isJson ? 'import_json' : 'import_excel'; + + const { rows } = parseFile(tempPath, origName || 'file.xlsx'); + + let inserted = 0, skipped = 0; + const errors = []; + + const tx = db.transaction(() => { + for (let idx = 0; idx < rows.length; idx++) { + const row = rows[idx]; + try { + const v = (target) => { + const col = mapping[target]; + if (col && row[col] !== undefined && row[col] !== null && row[col] !== '') { + return row[col]; + } + return defaults[target]; + }; + + if (module === 'depots_retraits') { + 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')), + v('libelle') || null, + v('reference') || null, + srcLabel, + ); + + } else if (module === 'investissements') { + db.prepare(` + INSERT INTO investissements + (investisseur_id, plateforme_id, nom_projet, emetteur, date_souscription, + date_premiere_echeance, date_cible, montant_investi, taux_interet, duree_mois, + type_remb, freq_interets, statut, reference, source) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + `).run( + req.investisseur.id, + Number(v('plateforme_id')), + String(v('nom_projet')), + v('emetteur') || null, + normaliseDate(v('date_souscription')), + v('date_premiere_echeance') ? normaliseDate(v('date_premiere_echeance')) : null, + v('date_cible') ? normaliseDate(v('date_cible')) : null, + num(v('montant_investi')), + v('taux_interet') ? Number(String(v('taux_interet')).replace(',', '.')) : null, + v('duree_mois') ? parseInt(v('duree_mois'), 10) : null, + v('type_remb') || null, + v('freq_interets') || 'mensuel', + v('statut') || 'en_cours', + v('reference') || null, + srcLabel, + ); + + } else if (module === 'remboursements') { + const capital = num(v('capital')); + const cashback = num(v('cashback')); + const bruts = num(v('interets_bruts')); + const ps = num(v('prelev_sociaux')); + 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; + 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')), + capital, cashback, bruts, ps, pf, interets_nets, net_recu, + v('statut') || 'paye', + srcLabel, + ); + + } 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`); + + } else if (module === 'taux_pfu') { + const annee = parseInt(v('annee'), 10); + if (!annee || annee < 2000 || annee > 2100) throw new Error('Année invalide'); + db.prepare(` + INSERT INTO taux_pfu (annee, pfu_total, impot_revenu, prelev_sociaux) + VALUES (?, ?, ?, ?) + ON CONFLICT(annee) DO UPDATE SET + pfu_total = excluded.pfu_total, + impot_revenu = excluded.impot_revenu, + prelev_sociaux = excluded.prelev_sociaux, + updated_at = datetime('now') + `).run( + annee, + num(v('pfu_total')), + num(v('impot_revenu')), + num(v('prelev_sociaux')), + ); + } + + inserted++; + } catch (err) { + skipped++; + errors.push({ row: idx + 2, error: err.message }); + } + } + }); + tx(); + + db.prepare(` + INSERT INTO imports (user_id, investisseur_id, module, filename, rows_total, rows_inserted, rows_skipped, mapping_json) + VALUES (?,?,?,?,?,?,?,?) + `).run( + req.user.id, + req.investisseur?.id ?? null, + module, + tempId, + rows.length, + inserted, + skipped, + JSON.stringify(mapping), + ); + + // Clean up temp file + try { fs.unlinkSync(tempPath); } catch { /* */ } + + res.json({ inserted, skipped, total: rows.length, errors: errors.slice(0, 50) }); + } catch (e) { next(e); } +}); + +/** + * POST /api/imports/dossier + * Importe un dossier investissement complet (format d'export natif). + * Scénario CREATE : le dossier n'existe pas → création complète. + * Scénario UPDATE : le dossier existe déjà → mise à jour des champs + remboursements manquants. + * Identification : (investisseur_id, nom_projet, date_souscription) — clé naturelle portable. + */ +router.post('/dossier', (req, res, next) => { + try { + requireInvestisseur(req, res, () => {}); + + const { dossier } = req.body || {}; + if (!dossier || dossier.type !== 'dossier_investissement') { + throw new HttpError(400, 'Format invalide — attendu { dossier: { type: "dossier_investissement", ... } }'); + } + const { investissement: inv, plateforme: platInfo, remboursements = [], reinvestissements = [], historique = [] } = dossier; + if (!inv?.nom_projet || !inv?.date_souscription) { + throw new HttpError(400, 'Champs obligatoires manquants : nom_projet, date_souscription'); + } + + let action, investissementId; + + const tx = db.transaction(() => { + /* ── 1. Résoudre / créer la plateforme ─────────────────── */ + let platRow = db.prepare('SELECT id FROM plateformes WHERE user_id = ? AND nom = ?') + .get(req.user.id, platInfo?.nom || ''); + if (!platRow && platInfo?.nom) { + const r = db.prepare('INSERT INTO plateformes (user_id, nom, url) VALUES (?,?,?)') + .run(req.user.id, platInfo.nom, platInfo.url || null); + platRow = { id: r.lastInsertRowid }; + } + if (!platRow) throw new HttpError(400, 'Plateforme introuvable et nom manquant dans le dossier'); + const plateformeId = platRow.id; + + /* ── 2. Chercher l'investissement existant (clé naturelle) */ + const existing = db.prepare(` + SELECT id FROM investissements + WHERE investisseur_id = ? AND nom_projet = ? AND date_souscription = ? + LIMIT 1 + `).get(req.investisseur.id, inv.nom_projet, inv.date_souscription); + + if (!existing) { + /* ────────────── SCÉNARIO CREATE ──────────────────────── */ + const r = db.prepare(` + INSERT INTO investissements + (investisseur_id, plateforme_id, nom_projet, emetteur, date_souscription, + date_premiere_echeance, date_cible, date_debut_simul, montant_investi, + taux_interet, duree_mois, type_remb, freq_interets, statut, reference, source, notes) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, 'import_dossier', ?) + `).run( + req.investisseur.id, plateformeId, + inv.nom_projet, inv.emetteur || null, + inv.date_souscription, + 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', inv.reference || null, inv.notes || null, + ); + investissementId = Number(r.lastInsertRowid); + + // Remboursements + for (const rb of remboursements) { + db.prepare(` + INSERT INTO remboursements + (investissement_id, date_remb, capital, cashback, interets_bruts, + prelev_sociaux, prelev_forfaitaire, interets_nets, net_recu, statut, notes, source) + VALUES (?,?,?,?,?,?,?,?,?,?,?, 'import_dossier') + `).run( + investissementId, rb.date_remb, + rb.capital || 0, rb.cashback || 0, rb.interets_bruts || 0, + rb.prelev_sociaux || 0, rb.prelev_forfaitaire || 0, + rb.interets_nets || 0, rb.net_recu || 0, + rb.statut || 'paye', rb.notes || null, + ); + } + + // Réinvestissements + for (const rv of reinvestissements) { + if (!rv.date_reinvestissement || !rv.montant) continue; + db.prepare(` + INSERT INTO reinvestissements (investissement_id, montant, date_reinvestissement, note, source) + VALUES (?, ?, ?, ?, ?) + `).run( + investissementId, + Number(rv.montant), + rv.date_reinvestissement, + rv.note || null, + rv.source || 'manuel', + ); + } + + // Historique (lecture seule — on importe pour la traçabilité) + for (const h of historique) { + db.prepare(` + INSERT INTO investissement_historique + (investissement_id, type_evenement, changements, notes, created_at) + VALUES (?,?,?,?,?) + `).run( + investissementId, + h.type_evenement || 'import', + typeof h.changements === 'string' ? h.changements : JSON.stringify(h.changements || []), + h.notes || null, + h.created_at || null, + ); + } + // Entrée d'historique de l'import lui-même + db.prepare(` + INSERT INTO investissement_historique (investissement_id, type_evenement, changements) + VALUES (?, 'import', ?) + `).run(investissementId, JSON.stringify([{ + champ: 'import', label: 'Import dossier', + ancienne_valeur: null, nouvelle_valeur: dossier.exported_at || 'inconnu', + }])); + + action = 'created'; + } else { + /* ────────────── SCÉNARIO UPDATE ──────────────────────── */ + investissementId = existing.id; + db.prepare(` + UPDATE investissements SET + plateforme_id = ?, emetteur = ?, + date_premiere_echeance = ?, date_cible = ?, date_debut_simul = ?, + montant_investi = ?, taux_interet = ?, duree_mois = ?, + type_remb = ?, freq_interets = ?, statut = ?, + 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', + inv.reference || null, inv.notes || null, + investissementId, + ); + + // Remboursements : ajouter les manquants (clé naturelle = date_remb + capital + interets_bruts) + let rembInserted = 0; + for (const rb of remboursements) { + const exists = db.prepare(` + SELECT id FROM remboursements + WHERE investissement_id = ? AND date_remb = ? + AND ABS(capital - ?) < 0.005 AND ABS(interets_bruts - ?) < 0.005 + `).get(investissementId, rb.date_remb, rb.capital || 0, rb.interets_bruts || 0); + if (!exists) { + db.prepare(` + INSERT INTO remboursements + (investissement_id, date_remb, capital, cashback, interets_bruts, + prelev_sociaux, prelev_forfaitaire, interets_nets, net_recu, statut, notes, source) + VALUES (?,?,?,?,?,?,?,?,?,?,?, 'import_dossier') + `).run( + investissementId, rb.date_remb, + rb.capital || 0, rb.cashback || 0, rb.interets_bruts || 0, + rb.prelev_sociaux || 0, rb.prelev_forfaitaire || 0, + rb.interets_nets || 0, rb.net_recu || 0, + rb.statut || 'paye', rb.notes || null, + ); + rembInserted++; + } + } + + // Réinvestissements : ajouter les manquants (clé = date + montant) + let reinvInserted = 0; + for (const rv of reinvestissements) { + if (!rv.date_reinvestissement || !rv.montant) continue; + const rvExists = db.prepare(` + SELECT id FROM reinvestissements + WHERE investissement_id = ? AND date_reinvestissement = ? AND ABS(montant - ?) < 0.005 + `).get(investissementId, rv.date_reinvestissement, Number(rv.montant)); + if (!rvExists) { + db.prepare(` + INSERT INTO reinvestissements (investissement_id, montant, date_reinvestissement, note, source) + VALUES (?, ?, ?, ?, ?) + `).run( + investissementId, + Number(rv.montant), + rv.date_reinvestissement, + rv.note || null, + rv.source || 'manuel', + ); + reinvInserted++; + } + } + + // 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)`, + }])); + + action = 'updated'; + } + }); + tx(); + + /* ── 3. Régénérer la simulation ─────────────────────────── */ + const fresh = db.prepare('SELECT * FROM investissements WHERE id = ?').get(investissementId); + generateSimulWithReinvestissements(db, investissementId); + + /* ── 4. Log import ──────────────────────────────────────── */ + db.prepare(` + INSERT INTO imports (user_id, investisseur_id, module, filename, rows_total, rows_inserted, rows_skipped, mapping_json) + VALUES (?,?,?,?,?,?,?,?) + `).run( + req.user.id, req.investisseur.id, + 'dossier_investissement', + `Dossier_${inv.nom_projet}`, + 1, 1, 0, + JSON.stringify({ action, investissementId }), + ); + + res.json({ action, investissementId }); + } catch (e) { next(e); } +}); + +router.get('/history', (req, res) => { + const rows = db.prepare(` + SELECT * FROM imports WHERE user_id=? ORDER BY id DESC LIMIT 100 + `).all(req.user.id); + res.json(rows); +}); + +// helpers + +/** Supprime les accents/diacritiques d'une chaîne (ex. "Dépôt" → "Depot") */ +function stripAccents(s) { + return String(s).normalize('NFD').replace(/[̀-ͯ]/g, ''); +} + +/** Convertit une valeur monétaire en nombre (gère "€", espaces, virgule décimale) */ +function num(v) { + if (v === undefined || v === null || v === '') return 0; + // Supprimer tout ce qui n'est pas chiffre, virgule, point ou signe moins + const clean = String(v).replace(/[^\d,.-]/g, '').replace(',', '.'); + const n = Number(clean); + return isNaN(n) ? 0 : n; +} + +function normaliseType(v) { + // Normalise les accents avant la comparaison : "Dépôt" → "depot" + const s = stripAccents(String(v || '')).toLowerCase(); + if (s.startsWith('dep') || s === 'versement' || s === 'in') return 'depot'; + if (s.startsWith('ret') || s === 'withdrawal' || s === 'out') return 'retrait'; + return s; // CHECK constraint will reject if invalid +} +function normaliseDate(v) { + if (!v) return null; + if (v instanceof Date) return v.toISOString().slice(0, 10); + const s = String(v).trim(); + // Already ISO + if (/^\d{4}-\d{2}-\d{2}/.test(s)) return s.slice(0, 10); + // dd/mm/yyyy + const m = s.match(/^(\d{1,2})[/.-](\d{1,2})[/.-](\d{2,4})$/); + if (m) { + const [_, d, mo, y] = m; + const yy = y.length === 2 ? '20' + y : y; + return `${yy}-${mo.padStart(2, '0')}-${d.padStart(2, '0')}`; + } + return s; +} + +export default router; diff --git a/backend/src/routes/investissements.js b/backend/src/routes/investissements.js new file mode 100644 index 0000000..d88d8fa --- /dev/null +++ b/backend/src/routes/investissements.js @@ -0,0 +1,457 @@ +import { Router } from 'express'; +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'; + +const router = Router(); + +const TRACKED_FIELDS = [ + { key: 'type_remb', label: 'Type de prêt' }, + { key: 'taux_interet', label: 'Taux annuel (%)' }, + { key: 'duree_mois', label: 'Durée (mois)' }, + { key: 'montant_investi', label: 'Montant investi (€)' }, + { key: 'statut', label: 'Statut' }, + { key: 'freq_interets', label: 'Fréquence des intérêts' }, + { key: 'date_premiere_echeance', label: 'Date 1ère échéance' }, + { key: 'date_cible', label: 'Date cible' }, + { key: 'date_debut_simul', label: 'Date de restructuration' }, + { key: 'plateforme_id', label: 'Plateforme' }, +]; + +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) + VALUES (?, ?, ?, ?) + `).run(investissementId, type_evenement, JSON.stringify(changements), notes || null); +} + +function detectChangements(ancien, nouveau) { + const diffs = []; + for (const { key, label } of TRACKED_FIELDS) { + const av = ancien[key] ?? null; + const nv = nouveau[key] ?? null; + const avNorm = av === '' ? null : av; + const nvNorm = nv === '' ? null : nv; + if (String(avNorm) !== String(nvNorm)) { + diffs.push({ champ: key, label, ancienne_valeur: avNorm, nouvelle_valeur: nvNorm }); + } + } + return diffs; +} + +function detectTypeEvenement(changements) { + const champsRestructuration = ['type_remb', 'date_debut_simul']; + if (changements.some(c => champsRestructuration.includes(c.champ))) return 'restructuration'; + return 'modification'; +} + +const Schema = z.object({ + investisseur_id: z.number().int().positive().optional(), + plateforme_id: z.number().int().positive(), + nom_projet: z.string().min(1), + emetteur: z.string().optional(), + date_souscription: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + date_premiere_echeance: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().or(z.literal('')), + date_cible: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().or(z.literal('')), + date_debut_simul: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().or(z.literal('')), + montant_investi: z.number().positive(), + taux_interet: z.number().optional(), + duree_mois: z.number().int().optional(), + type_remb: z.enum(['in_fine','amortissable','differe']).optional().or(z.literal('')), + freq_interets: z.enum(['mensuel','trimestriel','in_fine']).default('mensuel'), + statut: z.enum(['en_cours','rembourse','en_retard','procedure','cloture']).default('en_cours'), + reference: z.string().optional(), + notes: z.string().optional(), + categorie_id: z.number().int().positive().nullable().optional(), + echeance_fin_de_mois: z.number().int().min(0).max(1).optional().default(0), + methode_remboursement: z.enum(['portefeuille','compte_courant']).nullable().optional(), + nom_compte_courant: z.string().nullable().optional(), + compte_id: z.number().int().positive().nullable().optional(), + pays_exposition: z.string().length(2).optional().default('FR'), +}); + +router.use(requireInvestisseur); + +function resolveInvestisseurId(req, bodyInvestisseurId) { + if (!bodyInvestisseurId) return req.investisseur.id; + const row = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?') + .get(bodyInvestisseurId, req.user.id); + if (!row) throw new HttpError(403, 'Investisseur non autorisé'); + return bodyInvestisseurId; +} + +router.get('/', (req, res) => { + const scopeAll = req.query.scope === 'all'; + const { statut, plateforme_id } = req.query; + + const conds = scopeAll + ? ['i.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'] + : ['i.investisseur_id = ?']; + const args = scopeAll ? [req.user.id] : [req.investisseur.id]; + + if (statut) { conds.push('i.statut = ?'); args.push(statut); } + if (plateforme_id){ conds.push('i.plateforme_id = ?'); args.push(Number(plateforme_id)); } + + const rows = db.prepare(` + SELECT i.*, p.nom AS plateforme_nom, + inv.nom AS investisseur_nom, + cp.nom AS categorie_nom, + plat_inv.nom AS plateforme_detenteur_nom, + c.id AS compte_id, c.nom AS compte_nom, c.type AS compte_type, + (SELECT COALESCE(SUM(r.capital),0) FROM remboursements r WHERE r.investissement_id = i.id) AS capital_rembourse, + (SELECT COALESCE(SUM(r.interets_bruts),0) FROM remboursements r WHERE r.investissement_id = i.id) AS interets_percus, + (SELECT COALESCE(SUM(r.interets_nets),0) FROM remboursements r WHERE r.investissement_id = i.id) AS interets_nets_total, + (SELECT COALESCE(SUM(r.net_recu),0) FROM remboursements r WHERE r.investissement_id = i.id) AS net_recu_total, + (SELECT COALESCE(SUM(rv.montant),0) FROM reinvestissements rv WHERE rv.investissement_id = i.id) AS reinvestissements_total, + i.montant_investi + (SELECT COALESCE(SUM(rv.montant),0) FROM reinvestissements rv WHERE rv.investissement_id = i.id) AS capital_total + FROM investissements i + JOIN plateformes p ON p.id = i.plateforme_id + JOIN investisseurs inv ON inv.id = i.investisseur_id + LEFT JOIN investisseurs plat_inv ON plat_inv.id = p.investisseur_id + LEFT JOIN categories_plateforme cp ON cp.id = i.categorie_id + LEFT JOIN comptes c ON c.id = i.compte_id + WHERE ${conds.join(' AND ')} + ORDER BY i.date_souscription DESC, i.id DESC + `).all(...args); + + // Attacher les associations catégories/secteurs d'investissement + if (rows.length > 0) { + const ids = rows.map(r => r.id); + const placeholders = ids.map(() => '?').join(','); + const cats = db.prepare(` + SELECT ic.investissement_id, c.id, c.nom, + CASE WHEN c.user_id IS NULL THEN 1 ELSE 0 END AS is_global + FROM investissement_categories_inv ic + JOIN categories_inv c ON c.id = ic.categorie_id + WHERE ic.investissement_id IN (${placeholders}) + ORDER BY is_global DESC, c.nom + `).all(...ids); + const sects = db.prepare(` + SELECT is2.investissement_id, s.id, s.nom, + CASE WHEN s.user_id IS NULL THEN 1 ELSE 0 END AS is_global + FROM investissement_secteurs_inv is2 + JOIN secteurs_inv s ON s.id = is2.secteur_id + WHERE is2.investissement_id IN (${placeholders}) + ORDER BY is_global DESC, s.nom + `).all(...ids); + const catMap = {}; + const sectMap = {}; + for (const c of cats) { if (!catMap[c.investissement_id]) catMap[c.investissement_id] = []; catMap[c.investissement_id].push({ id: c.id, nom: c.nom, is_global: c.is_global }); } + for (const s of sects) { if (!sectMap[s.investissement_id]) sectMap[s.investissement_id] = []; sectMap[s.investissement_id].push({ id: s.id, nom: s.nom, is_global: s.is_global }); } + for (const r of rows) { r.categories_inv = catMap[r.id] || []; r.secteurs_inv = sectMap[r.id] || []; } + } + + res.json(rows); +}); + +// Retourne les comptes bancaires d'un investisseur donné (pour le select dans le formulaire) +router.get('/comptes-par-investisseur/:investisseur_id', (req, res, next) => { + try { + const invId = Number(req.params.investisseur_id); + // Vérifie que l'investisseur appartient à l'utilisateur + const inv = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?') + .get(invId, req.user.id); + if (!inv) throw new HttpError(403, 'Investisseur non autorisé'); + const rows = db.prepare( + 'SELECT id, nom, type, banque FROM comptes WHERE investisseur_id = ? AND user_id = ? ORDER BY type, nom' + ).all(invId, req.user.id); + res.json(rows); + } catch (e) { next(e); } +}); + +router.get('/comptes-courants', (req, res) => { + const rows = db.prepare(` + SELECT DISTINCT i.investisseur_id, i.nom_compte_courant + FROM investissements i + JOIN investisseurs inv ON inv.id = i.investisseur_id AND inv.user_id = ? + WHERE i.nom_compte_courant IS NOT NULL AND i.nom_compte_courant != '' + ORDER BY i.nom_compte_courant + `).all(req.user.id); + res.json(rows); +}); + +// POST /api/investissements/fix-differe-dates +// 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. +router.post('/fix-differe-dates', (req, res, next) => { + try { + const rows = db.prepare(` + SELECT i.id, i.nom_projet, i.date_souscription, i.duree_mois, + i.date_premiere_echeance, i.date_cible + FROM investissements i + JOIN investisseurs inv ON inv.id = i.investisseur_id AND inv.user_id = ? + WHERE i.type_remb = 'differe' + AND i.date_souscription IS NOT NULL + AND i.duree_mois IS NOT NULL + `).all(req.user.id); + + const SEUIL_JOURS = 730; // 2 ans + + function addMonths(dateStr, months) { + const d = new Date(dateStr + 'T00:00:00Z'); + d.setUTCMonth(d.getUTCMonth() + months); + return d.toISOString().slice(0, 10); + } + + function diffJours(a, b) { + return Math.abs((new Date(a + 'T00:00:00Z') - new Date(b + 'T00:00:00Z')) / 86400000); + } + + const corriges = []; + const stmt = db.prepare(` + UPDATE investissements + SET date_premiere_echeance = ?, date_cible = ?, updated_at = datetime('now') + WHERE id = ? + `); + + for (const inv of rows) { + const dateCalculee = addMonths(inv.date_souscription, inv.duree_mois); + const ecartEcheance = inv.date_premiere_echeance + ? diffJours(inv.date_premiere_echeance, dateCalculee) : null; + const ecartCible = inv.date_cible + ? diffJours(inv.date_cible, dateCalculee) : null; + + const incoherent = + (ecartEcheance !== null && ecartEcheance > SEUIL_JOURS) || + (ecartCible !== null && ecartCible > SEUIL_JOURS) || + (inv.date_premiere_echeance === null) || + (inv.date_cible === null); + + if (incoherent) { + stmt.run(dateCalculee, dateCalculee, inv.id); + corriges.push({ + id: inv.id, + nom_projet: inv.nom_projet, + date_souscription: inv.date_souscription, + duree_mois: inv.duree_mois, + ancienne_date_premiere_echeance: inv.date_premiere_echeance, + ancienne_date_cible: inv.date_cible, + nouvelle_date: dateCalculee, + }); + } + } + + res.json({ updated: corriges.length, detail: corriges }); + } catch (err) { + next(err); + } +}); + +router.get('/:id', (req, res, next) => { + try { + const inv = db.prepare(` + SELECT i.*, p.nom AS plateforme_nom, p.fiscalite AS plateforme_fiscalite, p.logo_filename AS plateforme_logo, cp.nom AS categorie_nom, + c.id AS compte_id, c.nom AS compte_nom, c.type AS compte_type + FROM investissements i + JOIN plateformes p ON p.id = i.plateforme_id + JOIN investisseurs inv ON inv.id = i.investisseur_id AND inv.user_id = ? + LEFT JOIN categories_plateforme cp ON cp.id = i.categorie_id + LEFT JOIN comptes c ON c.id = i.compte_id + WHERE i.id = ? + `).get(req.user.id, req.params.id); + if (!inv) throw new HttpError(404, 'Not found'); + const remboursements = db.prepare( + 'SELECT * FROM remboursements WHERE investissement_id = ? ORDER BY date_remb' + ).all(req.params.id); + const simul = db.prepare( + 'SELECT * FROM simul_remboursements WHERE investissement_id = ? ORDER BY numero_echeance' + ).all(req.params.id); + 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 reinvestissements = db.prepare( + 'SELECT * FROM reinvestissements WHERE investissement_id = ? ORDER BY date_reinvestissement' + ).all(req.params.id); + const reinvestissements_total = reinvestissements.reduce((s, r) => s + r.montant, 0); + const capital_total = inv.montant_investi + reinvestissements_total; + // Associations catégories/secteurs + const categories_inv = db.prepare(` + SELECT c.id, c.nom, CASE WHEN c.user_id IS NULL THEN 1 ELSE 0 END AS is_global + FROM investissement_categories_inv ic + JOIN categories_inv c ON c.id = ic.categorie_id + WHERE ic.investissement_id = ? + ORDER BY is_global DESC, c.nom + `).all(req.params.id); + const secteurs_inv = db.prepare(` + SELECT s.id, s.nom, CASE WHEN s.user_id IS NULL THEN 1 ELSE 0 END AS is_global + FROM investissement_secteurs_inv is2 + JOIN secteurs_inv s ON s.id = is2.secteur_id + 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 }); + } catch (e) { next(e); } +}); + +router.post('/', (req, res, next) => { + try { + const body = Schema.parse(req.body); + const investisseurId = resolveInvestisseurId(req, body.investisseur_id); + const r = db.prepare(` + INSERT INTO investissements + (investisseur_id, plateforme_id, nom_projet, emetteur, date_souscription, + date_premiere_echeance, date_cible, date_debut_simul, montant_investi, taux_interet, duree_mois, + type_remb, freq_interets, statut, reference, source, notes, categorie_id, echeance_fin_de_mois, + methode_remboursement, nom_compte_courant, compte_id, pays_exposition) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, 'manuel', ?,?,?,?,?,?,?) + `).run( + investisseurId, body.plateforme_id, body.nom_projet, body.emetteur || null, + body.date_souscription, body.date_premiere_echeance || null, body.date_cible || null, + body.date_debut_simul || null, body.montant_investi, body.taux_interet ?? null, body.duree_mois ?? null, + body.type_remb || null, body.freq_interets, body.statut, body.reference || null, body.notes || null, + body.categorie_id ?? null, body.echeance_fin_de_mois ?? 0, + body.methode_remboursement ?? null, + body.nom_compte_courant || null, + body.compte_id ?? null, + body.pays_exposition ?? 'FR', + ); + const newId = r.lastInsertRowid; + recordHistory(newId, { + type_evenement: 'creation', + changements: [{ champ: 'creation', label: 'Création', ancienne_valeur: null, nouvelle_valeur: body.nom_projet }], + }); + generateSimul(db, { + id: newId, + montant_investi: body.montant_investi, + taux_interet: body.taux_interet ?? null, + duree_mois: body.duree_mois ?? null, + type_remb: body.type_remb || 'in_fine', + freq_interets: body.freq_interets || 'mensuel', + date_premiere_echeance: body.date_premiere_echeance || null, + date_debut_simul: body.date_debut_simul || null, + date_souscription: body.date_souscription, + echeance_fin_de_mois: body.echeance_fin_de_mois ?? 0, + }); + res.status(201).json({ id: newId, ...body }); + } catch (e) { next(e); } +}); + +router.put('/:id', (req, res, next) => { + try { + const body = Schema.parse(req.body); + const investisseurId = resolveInvestisseurId(req, body.investisseur_id); + const ancien = db.prepare(` + SELECT inv_data.* + FROM investissements inv_data + JOIN investisseurs inv ON inv.id = inv_data.investisseur_id AND inv.user_id = ? + WHERE inv_data.id = ? + `).get(req.user.id, req.params.id); + if (!ancien) throw new HttpError(404, 'Not found'); + const r = db.prepare(` + UPDATE investissements + SET investisseur_id=?, plateforme_id=?, nom_projet=?, emetteur=?, date_souscription=?, + date_premiere_echeance=?, date_cible=?, date_debut_simul=?, montant_investi=?, + taux_interet=?, duree_mois=?, type_remb=?, freq_interets=?, statut=?, + reference=?, notes=?, categorie_id=?, echeance_fin_de_mois=?, + methode_remboursement=?, nom_compte_courant=?, compte_id=?, pays_exposition=?, updated_at=datetime('now') + WHERE id=? + `).run( + investisseurId, body.plateforme_id, body.nom_projet, body.emetteur || null, + body.date_souscription, body.date_premiere_echeance || null, body.date_cible || null, + body.date_debut_simul || null, body.montant_investi, body.taux_interet ?? null, + body.duree_mois ?? null, body.type_remb || null, body.freq_interets, body.statut, + body.reference || null, body.notes || null, body.categorie_id ?? null, + body.echeance_fin_de_mois ?? 0, + body.methode_remboursement ?? null, + body.nom_compte_courant || null, + body.compte_id ?? null, + body.pays_exposition ?? 'FR', req.params.id, + ); + if (r.changes === 0) throw new HttpError(404, 'Not found'); + + // Cascade compte_id vers les remboursements existants de cet investissement + if (body.compte_id !== undefined) { + const newCompteId = body.methode_remboursement === 'compte_courant' ? (body.compte_id ?? null) : null; + db.prepare( + "UPDATE remboursements SET compte_id=? WHERE investissement_id=? AND methode_remboursement='compte_courant' AND compte_id IS NOT NULL" + ).run(newCompteId, req.params.id); + } + + const changements = detectChangements(ancien, { + ...body, + date_debut_simul: body.date_debut_simul || null, + date_premiere_echeance: body.date_premiere_echeance || null, + date_cible: body.date_cible || null, + }); + if (changements.length > 0) { + recordHistory(Number(req.params.id), { + type_evenement: detectTypeEvenement(changements), + changements, + }); + } + generateSimul(db, { + id: Number(req.params.id), + montant_investi: body.montant_investi, + taux_interet: body.taux_interet ?? null, + duree_mois: body.duree_mois ?? null, + type_remb: body.type_remb || 'in_fine', + freq_interets: body.freq_interets || 'mensuel', + date_premiere_echeance: body.date_premiere_echeance || null, + date_debut_simul: body.date_debut_simul || null, + date_souscription: body.date_souscription, + echeance_fin_de_mois: body.echeance_fin_de_mois ?? 0, + }); + res.json({ id: Number(req.params.id), ...body }); + } catch (e) { next(e); } +}); + +// PUT /api/investissements/:id/fiscalite-override { override: 'exonere' | null } +router.put('/:id/fiscalite-override', (req, res, next) => { + try { + const inv = db.prepare( + 'SELECT id FROM investissements WHERE id = ? AND investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)' + ).get(req.params.id, req.user.id); + if (!inv) throw new HttpError(404, 'Investissement introuvable'); + + const override = req.body.override === 'exonere' ? 'exonere' : null; + db.prepare('UPDATE investissements SET fiscalite_override = ? WHERE id = ?') + .run(override, req.params.id); + res.json({ fiscalite_override: override }); + } catch (e) { next(e); } +}); + +router.delete('/:id', (req, res, next) => { + try { + const r = db.prepare(` + DELETE FROM investissements + WHERE id = ? AND investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?) + `).run(req.params.id, req.user.id); + if (r.changes === 0) throw new HttpError(404, 'Not found'); + res.status(204).end(); + } catch (e) { next(e); } +}); + +router.delete('/:id/historique/:hid', (req, res, next) => { + try { + const r = db.prepare(` + DELETE FROM investissement_historique + WHERE id = ? AND investissement_id = ? + AND EXISTS ( + SELECT 1 FROM investissements + WHERE id = ? AND investisseur_id = ? + ) + `).run(req.params.hid, req.params.id, req.params.id, req.investisseur.id); + if (r.changes === 0) throw new HttpError(404, 'Not found'); + 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 { + const inv = db.prepare( + 'SELECT id FROM investissements WHERE id = ? AND investisseur_id = ?' + ).get(req.params.id, req.investisseur.id); + if (!inv) throw new HttpError(404, 'Investissement introuvable'); + + const active = req.body.active ? 1 : 0; + db.prepare('UPDATE investissements SET auto_reinvest = ? WHERE id = ?') + .run(active, req.params.id); + res.json({ auto_reinvest: !!active }); + } catch (e) { next(e); } +}); + +export default router; diff --git a/backend/src/routes/investisseurs.js b/backend/src/routes/investisseurs.js new file mode 100644 index 0000000..9c8b337 --- /dev/null +++ b/backend/src/routes/investisseurs.js @@ -0,0 +1,126 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import db from '../db/index.js'; +import { HttpError } from '../middleware/errorHandler.js'; + +const router = Router(); + +const Schema = z.object({ + nom: z.string().min(1), + prenom: z.string().optional().or(z.literal('')), + type: z.enum(['famille', 'entreprise']).default('famille'), + type_fiscal: z.string().optional(), + notes: z.string().optional(), +}); + +router.get('/', (req, res) => { + const rows = db + .prepare( + `SELECT id, nom, prenom, type, type_fiscal, is_principal, notes, created_at + FROM investisseurs WHERE user_id = ? + ORDER BY is_principal DESC, type, nom` + ) + .all(req.user.id); + res.json(rows); +}); + +router.post('/', (req, res, next) => { + try { + const body = Schema.parse(req.body); + const r = db + .prepare( + 'INSERT INTO investisseurs (user_id, nom, prenom, type, type_fiscal, notes) VALUES (?,?,?,?,?,?)' + ) + .run( + req.user.id, + body.nom, + body.prenom || null, + body.type, + body.type_fiscal || null, + body.notes || null, + ); + const invId = r.lastInsertRowid; + // Auto-créer un compte courant pour ce nouveau profil + // body.nom contient déjà le nom complet (ex. "Marine CROGUENNEC") + const label = `Compte courant — ${body.nom}`; + db.prepare( + 'INSERT INTO comptes (user_id, nom, type, investisseur_id) VALUES (?,?,?,?)' + ).run(req.user.id, label, 'compte_courant', invId); + + res.status(201).json({ id: invId, ...body }); + } catch (e) { next(e); } +}); + +router.put('/:id', (req, res, next) => { + try { + const body = Schema.parse(req.body); + const r = db + .prepare( + `UPDATE investisseurs SET nom=?, prenom=?, type=?, type_fiscal=?, notes=?, updated_at=datetime('now') + WHERE id=? AND user_id=?` + ) + .run( + body.nom, + body.prenom || null, + body.type, + body.type_fiscal || null, + body.notes || null, + req.params.id, + req.user.id, + ); + if (r.changes === 0) throw new HttpError(404, 'Not found'); + res.json({ id: Number(req.params.id), ...body }); + } catch (e) { next(e); } +}); + +router.post('/reassign-to-principal', (req, res, next) => { + try { + const principal = db + .prepare('SELECT id FROM investisseurs WHERE user_id = ? AND is_principal = 1') + .get(req.user.id); + if (!principal) throw new HttpError(400, 'Aucun compte principal trouvé.'); + + const principalId = principal.id; + const userId = req.user.id; + + const reassign = db.transaction(() => { + db.prepare( + `UPDATE investissements SET investisseur_id = ? + WHERE investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?) AND investisseur_id != ?` + ).run(principalId, userId, principalId); + + db.prepare( + `UPDATE depots_retraits SET investisseur_id = ? + WHERE investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?) AND investisseur_id != ?` + ).run(principalId, userId, principalId); + }); + + reassign(); + res.json({ ok: true, principal_id: principalId }); + } catch (e) { next(e); } +}); + +router.delete('/:id', (req, res, next) => { + try { + // Empêcher la suppression du compte principal + const target = db + .prepare('SELECT id, is_principal FROM investisseurs WHERE id=? AND user_id=?') + .get(req.params.id, req.user.id); + if (!target) throw new HttpError(404, 'Not found'); + if (target.is_principal) throw new HttpError(400, 'Impossible de supprimer le compte principal.'); + + // Empêcher la suppression si c'est le seul membre + const count = db + .prepare('SELECT COUNT(*) AS n FROM investisseurs WHERE user_id=?') + .get(req.user.id).n; + if (count <= 1) throw new HttpError(400, 'Impossible de supprimer le dernier profil.'); + + const r = db + .prepare('DELETE FROM investisseurs WHERE id=? AND user_id=?') + .run(req.params.id, req.user.id); + if (r.changes === 0) throw new HttpError(404, 'Not found'); + res.status(204).end(); + } catch (e) { next(e); } +}); + +export default router; diff --git a/backend/src/routes/notation.js b/backend/src/routes/notation.js new file mode 100644 index 0000000..1bf959d --- /dev/null +++ b/backend/src/routes/notation.js @@ -0,0 +1,135 @@ +import { Router } from 'express'; +import db from '../db/index.js'; +import { HttpError } from '../middleware/errorHandler.js'; + +const router = Router(); + +const TYPES_VALIDES = ['etoiles', 'lettres', 'score', 'custom']; + +/** Valide et normalise un critère entrant */ +function parseBody(body) { + const { plateforme_id, nom, type = 'etoiles', valeurs, min_val, max_val, description, ordre } = body; + if (!plateforme_id) throw new HttpError(400, 'plateforme_id est requis'); + if (!nom?.trim()) throw new HttpError(400, 'nom est requis'); + if (!TYPES_VALIDES.includes(type)) throw new HttpError(400, `type invalide — attendu : ${TYPES_VALIDES.join(', ')}`); + + // Validation selon le type + if (type === 'score') { + if (min_val === undefined || max_val === undefined) throw new HttpError(400, 'min_val et max_val requis pour le type score'); + if (Number(min_val) >= Number(max_val)) throw new HttpError(400, 'min_val doit être inférieur à max_val'); + } + if ((type === 'lettres' || type === 'custom') && (!valeurs || !valeurs.length)) { + throw new HttpError(400, `valeurs est requis pour le type ${type}`); + } + + return { + plateforme_id: Number(plateforme_id), + nom: nom.trim(), + type, + valeurs: (type === 'lettres' || type === 'custom') + ? JSON.stringify(Array.isArray(valeurs) ? valeurs : valeurs.split(',').map(v => v.trim()).filter(Boolean)) + : null, + min_val: type === 'score' ? Number(min_val) : null, + max_val: type === 'score' ? Number(max_val) : null, + description: description?.trim() || null, + ordre: Number(ordre ?? 0), + }; +} + +/** Enrichit les lignes DB (parse valeurs JSON) */ +function enrich(row) { + return { + ...row, + valeurs: row.valeurs ? JSON.parse(row.valeurs) : null, + }; +} + +/* ── GET /api/notation?plateforme_id=X ───────────────────────── */ +router.get('/', (req, res) => { + const { plateforme_id } = req.query; + if (plateforme_id) { + const rows = db.prepare(` + SELECT nc.* FROM notation_criteres nc + JOIN plateformes p ON p.id = nc.plateforme_id + WHERE nc.plateforme_id = ? AND p.user_id = ? + ORDER BY nc.ordre, nc.id + `).all(Number(plateforme_id), req.user.id); + return res.json(rows.map(enrich)); + } + // Tous les critères de l'utilisateur (toutes plateformes) + const rows = db.prepare(` + SELECT nc.*, p.nom AS plateforme_nom FROM notation_criteres nc + JOIN plateformes p ON p.id = nc.plateforme_id + WHERE p.user_id = ? + ORDER BY p.nom, nc.ordre, nc.id + `).all(req.user.id); + res.json(rows.map(enrich)); +}); + +/* ── POST /api/notation ──────────────────────────────────────── */ +router.post('/', (req, res, next) => { + try { + const data = parseBody(req.body); + // Vérifie que la plateforme appartient à l'utilisateur + const plat = db.prepare('SELECT id FROM plateformes WHERE id = ? AND user_id = ?') + .get(data.plateforme_id, req.user.id); + if (!plat) throw new HttpError(404, 'Plateforme introuvable'); + + const r = db.prepare(` + INSERT INTO notation_criteres (plateforme_id, nom, type, valeurs, min_val, max_val, description, ordre) + VALUES (?,?,?,?,?,?,?,?) + `).run(data.plateforme_id, data.nom, data.type, data.valeurs, data.min_val, data.max_val, data.description, data.ordre); + + const created = enrich(db.prepare('SELECT * FROM notation_criteres WHERE id = ?').get(r.lastInsertRowid)); + res.status(201).json(created); + } catch (e) { next(e); } +}); + +/* ── PUT /api/notation/:id ───────────────────────────────────── */ +router.put('/:id', (req, res, next) => { + try { + const existing = db.prepare(` + SELECT nc.* FROM notation_criteres nc + JOIN plateformes p ON p.id = nc.plateforme_id + WHERE nc.id = ? AND p.user_id = ? + `).get(req.params.id, req.user.id); + if (!existing) throw new HttpError(404, 'Critère introuvable'); + + const data = parseBody({ ...req.body, plateforme_id: existing.plateforme_id }); + db.prepare(` + UPDATE notation_criteres + SET nom=?, type=?, valeurs=?, min_val=?, max_val=?, description=?, ordre=? + WHERE id=? + `).run(data.nom, data.type, data.valeurs, data.min_val, data.max_val, data.description, data.ordre, req.params.id); + + res.json(enrich(db.prepare('SELECT * FROM notation_criteres WHERE id = ?').get(req.params.id))); + } catch (e) { next(e); } +}); + +/* ── DELETE /api/notation/:id ────────────────────────────────── */ +router.delete('/:id', (req, res, next) => { + try { + const existing = db.prepare(` + SELECT nc.id FROM notation_criteres nc + JOIN plateformes p ON p.id = nc.plateforme_id + WHERE nc.id = ? AND p.user_id = ? + `).get(req.params.id, req.user.id); + if (!existing) throw new HttpError(404, 'Critère introuvable'); + db.prepare('DELETE FROM notation_criteres WHERE id = ?').run(req.params.id); + res.json({ deleted: true }); + } catch (e) { next(e); } +}); + +/* ── PUT /api/notation/reorder ───────────────────────────────── */ +router.put('/reorder', (req, res, next) => { + try { + const { ids } = req.body; // tableau d'ids dans le nouvel ordre + if (!Array.isArray(ids)) throw new HttpError(400, 'ids[] requis'); + const upd = db.prepare('UPDATE notation_criteres SET ordre = ? WHERE id = ?'); + const tx = db.transaction(() => ids.forEach((id, i) => upd.run(i, id))); + tx(); + res.json({ reordered: true }); + } catch (e) { next(e); } +}); + +export default router; diff --git a/backend/src/routes/pfu.js b/backend/src/routes/pfu.js new file mode 100644 index 0000000..7daacf0 --- /dev/null +++ b/backend/src/routes/pfu.js @@ -0,0 +1,66 @@ +import { Router } from 'express'; +import db from '../db/index.js'; + +const router = Router(); + +/* GET /api/pfu — liste triée par année */ +router.get('/', (req, res) => { + const rows = db.prepare('SELECT * FROM taux_pfu ORDER BY annee ASC').all(); + res.json(rows); +}); + +/* POST /api/pfu — ajouter une année */ +router.post('/', (req, res) => { + const { annee, impot_revenu, csg, crds, solidarite } = req.body; + if (!annee || impot_revenu == null || csg == null || crds == null || solidarite == null) { + return res.status(400).json({ error: 'Champs requis : annee, impot_revenu, csg, crds, solidarite' }); + } + const prelev_sociaux = Number(csg) + Number(crds) + Number(solidarite); + const pfu_total = Number(impot_revenu) + prelev_sociaux; + try { + const stmt = db.prepare( + 'INSERT INTO taux_pfu (annee, pfu_total, impot_revenu, prelev_sociaux, csg, crds, solidarite) VALUES (?, ?, ?, ?, ?, ?, ?)' + ); + const result = stmt.run( + Number(annee), pfu_total, Number(impot_revenu), prelev_sociaux, + Number(csg), Number(crds), Number(solidarite) + ); + res.status(201).json(db.prepare('SELECT * FROM taux_pfu WHERE id = ?').get(result.lastInsertRowid)); + } catch (e) { + if (e.message?.includes('UNIQUE')) return res.status(409).json({ error: `L'année ${annee} existe déjà.` }); + throw e; + } +}); + +/* PUT /api/pfu/:id — modifier */ +router.put('/:id', (req, res) => { + const { annee, impot_revenu, csg, crds, solidarite } = req.body; + if (!annee || impot_revenu == null || csg == null || crds == null || solidarite == null) { + return res.status(400).json({ error: 'Champs requis : annee, impot_revenu, csg, crds, solidarite' }); + } + const prelev_sociaux = Number(csg) + Number(crds) + Number(solidarite); + const pfu_total = Number(impot_revenu) + prelev_sociaux; + try { + db.prepare( + `UPDATE taux_pfu SET annee=?, pfu_total=?, impot_revenu=?, prelev_sociaux=?, + csg=?, crds=?, solidarite=?, updated_at=datetime('now') WHERE id=?` + ).run( + Number(annee), pfu_total, Number(impot_revenu), prelev_sociaux, + Number(csg), Number(crds), Number(solidarite), Number(req.params.id) + ); + const row = db.prepare('SELECT * FROM taux_pfu WHERE id = ?').get(Number(req.params.id)); + if (!row) return res.status(404).json({ error: 'Introuvable' }); + res.json(row); + } catch (e) { + if (e.message?.includes('UNIQUE')) return res.status(409).json({ error: `L'année ${annee} existe déjà.` }); + throw e; + } +}); + +/* DELETE /api/pfu/:id */ +router.delete('/:id', (req, res) => { + db.prepare('DELETE FROM taux_pfu WHERE id = ?').run(Number(req.params.id)); + res.status(204).end(); +}); + +export default router; diff --git a/backend/src/routes/plateforme-tax.js b/backend/src/routes/plateforme-tax.js new file mode 100644 index 0000000..87a6ad9 --- /dev/null +++ b/backend/src/routes/plateforme-tax.js @@ -0,0 +1,70 @@ +import { Router } from 'express'; +import db from '../db/index.js'; + +const router = Router(); + +/* ── GET /api/plateforme-tax/:platId/:annee + Retourne l'enregistrement pour l'année. + S'il n'existe pas, clone depuis N-1 (ou crée vide). ── */ +router.get('/:platId/:annee', (req, res) => { + const platId = Number(req.params.platId); + const annee = Number(req.params.annee); + + // Vérifier que la plateforme appartient à l'utilisateur + const plat = db.prepare('SELECT id, nom FROM plateformes WHERE id = ? AND user_id = ?').get(platId, req.user.id); + if (!plat) return res.status(404).json({ error: 'Plateforme introuvable' }); + + let row = db.prepare('SELECT * FROM plateforme_tax_details WHERE plateforme_id = ? AND annee = ?').get(platId, annee); + + if (!row) { + // Chercher l'année précédente + const prev = db.prepare( + 'SELECT * FROM plateforme_tax_details WHERE plateforme_id = ? AND annee < ? ORDER BY annee DESC LIMIT 1' + ).get(platId, annee); + + const raison_sociale = prev?.raison_sociale ?? plat.nom; + const siret_n = prev?.siret_n ?? null; + // siret_n1 = le siret_n de l'année précédente (s'il a changé entre N-1 et N, on le garde) + const siret_n1 = prev?.siret_n ?? null; + + const r = db.prepare( + 'INSERT INTO plateforme_tax_details (plateforme_id, annee, raison_sociale, siret_n, siret_n1) VALUES (?,?,?,?,?)' + ).run(platId, annee, raison_sociale, siret_n, siret_n1); + + row = db.prepare('SELECT * FROM plateforme_tax_details WHERE id = ?').get(r.lastInsertRowid); + } + + res.json(row); +}); + +/* ── PATCH /api/plateforme-tax/:id — mise à jour des champs ── */ +router.patch('/:id', (req, res) => { + const id = Number(req.params.id); + const { raison_sociale, siret_n, siret_n1 } = req.body; + + // Vérifier l'appartenance via la plateforme + const row = db.prepare(` + SELECT ptd.id FROM plateforme_tax_details ptd + JOIN plateformes p ON p.id = ptd.plateforme_id + WHERE ptd.id = ? AND p.user_id = ? + `).get(id, req.user.id); + + if (!row) return res.status(404).json({ error: 'Enregistrement introuvable' }); + + db.prepare(` + UPDATE plateforme_tax_details + SET raison_sociale = COALESCE(?, raison_sociale), + siret_n = ?, + siret_n1 = ? + WHERE id = ? + `).run( + raison_sociale !== undefined ? raison_sociale : null, + siret_n !== undefined ? siret_n : null, + siret_n1 !== undefined ? siret_n1 : null, + id + ); + + res.json(db.prepare('SELECT * FROM plateforme_tax_details WHERE id = ?').get(id)); +}); + +export default router; diff --git a/backend/src/routes/plateformes.js b/backend/src/routes/plateformes.js new file mode 100644 index 0000000..018b80d --- /dev/null +++ b/backend/src/routes/plateformes.js @@ -0,0 +1,693 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import db from '../db/index.js'; +import { HttpError } from '../middleware/errorHandler.js'; +import { createZip, readZip } from '../utils/zip.js'; +import multer from 'multer'; +import path from 'node:path'; +import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const logosDir = path.resolve(__dirname, '../../../data/logos'); +fs.mkdirSync(logosDir, { recursive: true }); + +/** Sanitise un nom de plateforme pour en faire un nom de fichier safe */ +function sanitizeNom(nom) { + return (nom || 'plateforme') + .normalize('NFD').replace(/[̀-ͯ]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') || 'plateforme'; +} + +const ALLOWED_MIMES = { 'image/svg+xml': 'svg', 'image/png': 'png', 'image/jpeg': 'jpg', 'image/jpg': 'jpg' }; + +const logoStorage = multer.diskStorage({ + destination: (_req, _file, cb) => cb(null, logosDir), + filename: (req, file, cb) => { + const plat = db.prepare('SELECT nom FROM plateformes WHERE id = ? AND user_id = ?') + .get(req.params.id, req.user.id); + const ext = ALLOWED_MIMES[file.mimetype] || 'png'; + cb(null, `logo_${sanitizeNom(plat?.nom || String(req.params.id))}_${Date.now()}.${ext}`); + }, +}); + +const upload = multer({ + storage: logoStorage, + limits: { fileSize: 2 * 1024 * 1024 }, + fileFilter: (_req, file, cb) => { + if (ALLOWED_MIMES[file.mimetype]) cb(null, true); + else cb(new HttpError(400, 'Format non supporté (SVG, PNG, JPEG uniquement)')); + }, +}); + +const router = Router(); + +const Schema = z.object({ + nom: z.string().min(1), + url: z.string().url().optional().or(z.literal('')), + notes: z.string().optional(), + categories: z.array(z.number().int().positive()).optional().default([]), + domiciliation: z.string().min(1).max(100).default('france'), + fiscalite: z.enum(['flat_tax', 'sans_fiscalite_locale', 'avec_fiscalite_locale']).default('flat_tax'), + taux_fiscalite_locale: z.number().min(0).max(100).nullable().optional(), + type_produit_fiscal: z.enum(['2TT', '2TR']).default('2TT'), + methode_remboursement: z.enum(['portefeuille', 'compte_courant', 'choix_investisseur']).default('portefeuille'), + investisseur_id: z.number().int().positive().nullable().optional(), + date_ouverture: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(), + type_pret_defaut: z.enum(['in_fine', 'amortissable', 'differe']).nullable().optional(), + freq_interets_defaut: z.enum(['mensuel', 'trimestriel', 'in_fine']).nullable().optional(), + referentiel_id: z.number().int().positive().nullable().optional(), +}); + +// Champs hérités du référentiel (comparés pour calculer overridden_fields) +const HERITABLE_FIELDS = ['nom', 'url', 'domiciliation', 'fiscalite', 'taux_fiscalite_locale', 'type_produit_fiscal', 'logo_filename', 'icone_filename', 'methode_remboursement', 'type_pret_defaut', 'freq_interets_defaut']; + +/** + * Calcule overridden_fields : champs dont la valeur diffère du référentiel. + * Retourne le tableau mis à jour des champs overridés. + */ +function computeOverridden(ref, newValues, currentOverridden = []) { + if (!ref) return currentOverridden; + const overridden = new Set(currentOverridden); + for (const field of HERITABLE_FIELDS) { + const refVal = ref[field] ?? null; + const newVal = newValues[field] ?? null; + // Comparer en string pour éviter les faux positifs number vs null + if (String(refVal) !== String(newVal)) { + overridden.add(field); + } else { + overridden.delete(field); + } + } + return [...overridden]; +} + +function attachCategories(userId, rows) { + if (rows.length === 0) return rows; + const platIds = rows.map(r => r.id); + const cats = db.prepare(` + SELECT pc.plateforme_id, c.id, c.nom + FROM plateforme_categories pc + JOIN categories_plateforme c ON c.id = pc.categorie_id + WHERE c.user_id = ? + AND pc.plateforme_id IN (${platIds.map(() => '?').join(',')}) + ORDER BY c.nom + `).all(userId, ...platIds); + + const map = {}; + for (const c of cats) { + if (!map[c.plateforme_id]) map[c.plateforme_id] = []; + map[c.plateforme_id].push({ id: c.id, nom: c.nom }); + } + return rows.map(r => ({ ...r, categories: map[r.id] || [] })); +} + + +function attachCategoriesInv(rows) { + if (rows.length === 0) return rows; + const ids = rows.map(r => r.id); + + // Tags propres à chaque plateforme + const links = db.prepare(` + SELECT pc.plateforme_id, c.id AS categorie_id, c.nom + FROM plateforme_categories_inv pc + JOIN categories_inv c ON c.id = pc.categorie_id + WHERE pc.plateforme_id IN (${ids.map(() => '?').join(',')}) + ORDER BY c.nom + `).all(...ids); + const map = {}; + for (const l of links) { + if (!map[l.plateforme_id]) map[l.plateforme_id] = []; + map[l.plateforme_id].push({ id: l.categorie_id, nom: l.nom, is_inherited: false }); + } + + // Tags du référentiel pour les plateformes liées (héritage par fusion) + const refIds = [...new Set(rows.filter(r => r.referentiel_id).map(r => r.referentiel_id))]; + const refCatMap = {}; + if (refIds.length > 0) { + const refLinks = db.prepare(` + SELECT rc.referentiel_id, c.id AS categorie_id, c.nom + FROM referentiel_categories_inv rc + JOIN categories_inv c ON c.id = rc.categorie_id + WHERE rc.referentiel_id IN (${refIds.map(() => '?').join(',')}) + ORDER BY c.nom + `).all(...refIds); + for (const l of refLinks) { + if (!refCatMap[l.referentiel_id]) refCatMap[l.referentiel_id] = []; + refCatMap[l.referentiel_id].push({ id: l.categorie_id, nom: l.nom }); + } + } + + 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 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); + const merged = [...ownTags]; + for (const rt of refTags) { + if (!ownIds.has(rt.id)) merged.push({ ...rt, is_inherited: true }); + } + merged.sort((a, b) => a.nom.localeCompare(b.nom)); + return { ...r, categories_inv: merged }; + }); +} + +function attachSecteursInv(rows) { + if (rows.length === 0) return rows; + const ids = rows.map(r => r.id); + + const links = db.prepare(` + SELECT ps.plateforme_id, s.id AS secteur_id, s.nom + FROM plateforme_secteurs_inv ps + JOIN secteurs_inv s ON s.id = ps.secteur_id + WHERE ps.plateforme_id IN (${ids.map(() => '?').join(',')}) + ORDER BY s.nom + `).all(...ids); + const map = {}; + for (const l of links) { + if (!map[l.plateforme_id]) map[l.plateforme_id] = []; + map[l.plateforme_id].push({ id: l.secteur_id, nom: l.nom, is_inherited: false }); + } + + const refIds = [...new Set(rows.filter(r => r.referentiel_id).map(r => r.referentiel_id))]; + const refSectMap = {}; + if (refIds.length > 0) { + const refLinks = db.prepare(` + SELECT rs.referentiel_id, s.id AS secteur_id, s.nom + FROM referentiel_secteurs_inv rs + JOIN secteurs_inv s ON s.id = rs.secteur_id + WHERE rs.referentiel_id IN (${refIds.map(() => '?').join(',')}) + ORDER BY s.nom + `).all(...refIds); + for (const l of refLinks) { + if (!refSectMap[l.referentiel_id]) refSectMap[l.referentiel_id] = []; + refSectMap[l.referentiel_id].push({ id: l.secteur_id, nom: l.nom }); + } + } + + 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 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); + const merged = [...ownTags]; + for (const rt of refTags) { + if (!ownIds.has(rt.id)) merged.push({ ...rt, is_inherited: true }); + } + merged.sort((a, b) => a.nom.localeCompare(b.nom)); + return { ...r, secteurs_inv: merged }; + }); +} +function syncCategories(platId, catIds) { + db.prepare('DELETE FROM plateforme_categories WHERE plateforme_id=?').run(platId); + if (catIds.length === 0) return; + const ins = db.prepare( + 'INSERT OR IGNORE INTO plateforme_categories (plateforme_id, categorie_id) VALUES (?,?)' + ); + db.transaction((pid, ids) => { + for (const cid of ids) ins.run(pid, cid); + })(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 + FROM plateformes_referentiel pr + ORDER BY pr.nom + `).all(); + res.json(rows); +}); + +router.get('/', (req, res) => { + const rows = db.prepare(` + SELECT p.id, p.nom, p.url, p.notes, p.domiciliation, p.fiscalite, p.taux_fiscalite_locale, + p.type_produit_fiscal, + 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, + 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, + (SELECT COUNT(*) FROM investissements i + JOIN investisseurs inv2 ON inv2.id = i.investisseur_id + WHERE i.plateforme_id = p.id AND inv2.user_id = p.user_id) AS nb_investissements + FROM plateformes p + LEFT JOIN investisseurs inv ON inv.id = p.investisseur_id + LEFT JOIN plateformes_referentiel pr ON pr.id = p.referentiel_id + WHERE p.user_id = ? + ORDER BY p.nom + `).all(req.user.id); + const enriched = rows.map(r => ({ + ...r, + overridden_fields: JSON.parse(r.overridden_fields || '[]'), + })); + const withCats = attachCategories(req.user.id, enriched); + const withCatsInv = attachCategoriesInv(withCats); + const withAll = attachSecteursInv(withCatsInv); + res.json(withAll); +}); + +router.post('/', (req, res, next) => { + try { + const body = Schema.parse(req.body); + let fiscalite = body.domiciliation === 'FR' ? 'flat_tax' : body.fiscalite; + let taux = fiscalite === 'avec_fiscalite_locale' ? (body.taux_fiscalite_locale ?? null) : null; + let typeProduitFiscal = body.domiciliation === 'FR' ? (body.type_produit_fiscal ?? '2TT') : '2TT'; + let referentielId = body.referentiel_id ?? null; + + // Si un référentiel est sélectionné, hériter ses valeurs (pas encore d'overrides) + if (referentielId) { + const ref = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(referentielId); + if (!ref) throw new HttpError(404, 'Référentiel introuvable'); + // Les champs du body priment (l'user peut déjà avoir modifié à la création) + // On calcule les overrides par rapport au référentiel + } + + const r = db.prepare(` + INSERT INTO plateformes + (user_id, nom, url, notes, domiciliation, fiscalite, taux_fiscalite_locale, type_produit_fiscal, + methode_remboursement, investisseur_id, date_ouverture, + type_pret_defaut, freq_interets_defaut, + referentiel_id, overridden_fields) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + `).run( + req.user.id, body.nom, body.url || null, body.notes || null, + body.domiciliation, fiscalite, taux, typeProduitFiscal, + body.methode_remboursement, body.investisseur_id ?? null, body.date_ouverture || null, + body.type_pret_defaut ?? null, body.freq_interets_defaut ?? null, + referentielId, + '[]' // toujours vide à la création — les overrides se calculent au premier PUT + ); + const id = r.lastInsertRowid; + syncCategories(id, body.categories); + res.status(201).json({ + id, ...body, + fiscalite, taux_fiscalite_locale: taux, type_produit_fiscal: typeProduitFiscal, + referentiel_id: referentielId, overridden_fields: [], + }); + } catch (e) { next(e); } +}); + +// ── Multer memStorage for ZIP uploads ───────────────────────────────────── +const zipUpload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 50 * 1024 * 1024 }, + fileFilter: (_req, file, cb) => { + if (file.mimetype === 'application/zip' || file.originalname.endsWith('.zip')) cb(null, true); + else cb(new HttpError(400, 'Fichier ZIP attendu')); + }, +}); + +// ── GET /api/plateformes/export — exporte toutes les plateformes de l'user ─ +router.get('/export', (req, res, next) => { + try { + let rows = db.prepare(` + SELECT p.*, inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom + FROM plateformes p + LEFT JOIN investisseurs inv ON inv.id = p.investisseur_id + WHERE p.user_id = ? + ORDER BY p.nom + `).all(req.user.id); + rows = attachCategoriesInv(rows); + rows = attachSecteursInv(rows); + + // Attach legacy categories + const platIds = rows.map(r => r.id); + let catsLegacy = []; + if (platIds.length > 0) { + catsLegacy = db.prepare(` + SELECT pc.plateforme_id, c.nom + FROM plateforme_categories pc JOIN categories_plateforme c ON c.id = pc.categorie_id + WHERE c.user_id = ? AND pc.plateforme_id IN (${platIds.map(() => '?').join(',')}) + `).all(req.user.id, ...platIds); + } + const legacyMap = {}; + for (const c of catsLegacy) { + if (!legacyMap[c.plateforme_id]) legacyMap[c.plateforme_id] = []; + legacyMap[c.plateforme_id].push(c.nom); + } + + const entries = []; + entries.push({ name: 'manifest.json', data: JSON.stringify({ version: '1.0', app: 'crowdlending', exported_at: new Date().toISOString(), count: rows.length, type: 'plateformes' }, null, 2) }); + + const dataRows = rows.map(r => ({ + nom: r.nom, + url: r.url, + notes: r.notes, + domiciliation: r.domiciliation, + fiscalite: r.fiscalite, + taux_fiscalite_locale: r.taux_fiscalite_locale, + type_produit_fiscal: r.type_produit_fiscal, + methode_remboursement: r.methode_remboursement, + date_ouverture: r.date_ouverture, + type_pret_defaut: r.type_pret_defaut, + freq_interets_defaut: r.freq_interets_defaut, + logo_filename: r.logo_filename, + icone_filename: r.icone_filename, + investisseur_nom: r.investisseur_nom, + investisseur_prenom: r.investisseur_prenom, + categories: legacyMap[r.id] || [], + categories_inv: (r.categories_inv || []).map(c => c.nom), + secteurs_inv: (r.secteurs_inv || []).map(s => s.nom), + })); + + entries.push({ name: 'data.json', data: JSON.stringify(dataRows, null, 2) }); + + for (const r of rows) { + for (const fname of [r.logo_filename, r.icone_filename]) { + if (!fname) continue; + const fpath = path.join(logosDir, fname); + if (fs.existsSync(fpath)) entries.push({ name: `logos/${fname}`, data: fs.readFileSync(fpath) }); + } + } + + const zipBuf = createZip(entries); + const slug = 'plateformes-' + new Date().toISOString().slice(0, 10); + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', `attachment; filename="${slug}.zip"`); + res.send(zipBuf); + } catch (e) { next(e); } +}); + +// ── GET /api/plateformes/:id/export — exporte une plateforme ────────────── +router.get('/:id/export', (req, res, next) => { + try { + const p = db.prepare(` + SELECT p.*, inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom + FROM plateformes p LEFT JOIN investisseurs inv ON inv.id = p.investisseur_id + WHERE p.id = ? AND p.user_id = ? + `).get(req.params.id, req.user.id); + if (!p) throw new HttpError(404, 'Plateforme introuvable'); + + const [withCatsInv] = attachCategoriesInv([p]); + const [withAll] = attachSecteursInv([withCatsInv]); + + const catsLegacy = db.prepare(` + SELECT c.nom FROM plateforme_categories pc + JOIN categories_plateforme c ON c.id = pc.categorie_id + WHERE pc.plateforme_id = ? AND c.user_id = ? + `).all(p.id, req.user.id).map(c => c.nom); + + const entries = []; + entries.push({ name: 'manifest.json', data: JSON.stringify({ version: '1.0', app: 'crowdlending', exported_at: new Date().toISOString(), count: 1, type: 'plateformes' }, null, 2) }); + + const dataRow = { + nom: withAll.nom, url: withAll.url, notes: withAll.notes, + domiciliation: withAll.domiciliation, fiscalite: withAll.fiscalite, + taux_fiscalite_locale: withAll.taux_fiscalite_locale, + type_produit_fiscal: withAll.type_produit_fiscal, + methode_remboursement: withAll.methode_remboursement, + date_ouverture: withAll.date_ouverture, + type_pret_defaut: withAll.type_pret_defaut, freq_interets_defaut: withAll.freq_interets_defaut, + logo_filename: withAll.logo_filename, icone_filename: withAll.icone_filename, + investisseur_nom: withAll.investisseur_nom, investisseur_prenom: withAll.investisseur_prenom, + categories: catsLegacy, + categories_inv: (withAll.categories_inv || []).map(c => c.nom), + secteurs_inv: (withAll.secteurs_inv || []).map(s => s.nom), + }; + entries.push({ name: 'data.json', data: JSON.stringify([dataRow], null, 2) }); + + for (const fname of [withAll.logo_filename, withAll.icone_filename]) { + if (!fname) continue; + const fpath = path.join(logosDir, fname); + if (fs.existsSync(fpath)) entries.push({ name: `logos/${fname}`, data: fs.readFileSync(fpath) }); + } + + const zipBuf = createZip(entries); + const slug = withAll.nom.toLowerCase().replace(/[^a-z0-9]+/g, '-') + '-' + new Date().toISOString().slice(0, 10); + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', `attachment; filename="${slug}.zip"`); + res.send(zipBuf); + } catch (e) { next(e); } +}); + +// ── POST /api/plateformes/import-zip — importe un ZIP de plateformes ────── +router.post('/import-zip', zipUpload.single('file'), async (req, res, next) => { + try { + if (!req.file) throw new HttpError(400, 'Fichier ZIP manquant'); + const zipEntries = readZip(req.file.buffer); + const dataEntry = zipEntries.find(e => e.name === 'data.json'); + if (!dataEntry) throw new HttpError(400, 'ZIP invalide : data.json manquant'); + + const platforms = JSON.parse(dataEntry.data.toString('utf8')); + if (!Array.isArray(platforms)) throw new HttpError(400, 'data.json doit être un tableau'); + + // Résoudre investisseur par nom+prénom + const userInvestisseurs = db.prepare('SELECT * FROM investisseurs WHERE user_id = ?').all(req.user.id); + function resolveInvestisseur(nom, prenom) { + if (!nom) return userInvestisseurs[0]?.id ?? null; + const match = userInvestisseurs.find(i => + i.nom?.toLowerCase() === nom?.toLowerCase() && i.prenom?.toLowerCase() === prenom?.toLowerCase() + ) || userInvestisseurs.find(i => i.nom?.toLowerCase() === nom?.toLowerCase()); + return match?.id ?? userInvestisseurs[0]?.id ?? null; + } + + // Résoudre categories_inv et secteurs_inv par nom (globaux puis user) + function resolveTagIds(names, table) { + const ids = []; + for (const nom of (names || [])) { + const trimmed = nom.trim(); + if (!trimmed) continue; + let row = db.prepare(`SELECT id FROM ${table} WHERE nom = ? AND (user_id IS NULL OR user_id = ?)`).get(trimmed, req.user.id); + if (!row) { + const r = db.prepare(`INSERT INTO ${table} (nom, user_id) VALUES (?, ?)`).run(trimmed, req.user.id); + row = { id: r.lastInsertRowid }; + } + ids.push(row.id); + } + return ids; + } + + // Résoudre catégories legacy + function resolveLegacyCatIds(noms) { + const ids = []; + for (const nom of (noms || [])) { + const trimmed = nom.trim(); + if (!trimmed) continue; + let row = db.prepare('SELECT id FROM categories_plateforme WHERE nom = ? AND user_id = ?').get(trimmed, req.user.id); + if (!row) { + const r = db.prepare('INSERT INTO categories_plateforme (nom, user_id) VALUES (?, ?)').run(trimmed, req.user.id); + row = { id: r.lastInsertRowid }; + } + ids.push(row.id); + } + return ids; + } + + const imageMap = {}; + for (const e of zipEntries) { + if (e.name.startsWith('logos/') && e.name.length > 6) imageMap[path.basename(e.name)] = e.data; + } + + let created = 0; + let updated = 0; + const tx = db.transaction(() => { + for (const p of platforms) { + if (!p.nom) continue; + const fiscalite = p.domiciliation === 'FR' ? 'flat_tax' : (p.fiscalite || 'flat_tax'); + const taux = fiscalite === 'avec_fiscalite_locale' ? (p.taux_fiscalite_locale ?? null) : null; + const investisseurId = resolveInvestisseur(p.investisseur_nom, p.investisseur_prenom); + const catsInvIds = resolveTagIds(p.categories_inv, 'categories_inv'); + const sectsInvIds = resolveTagIds(p.secteurs_inv, 'secteurs_inv'); + const legacyCatIds = resolveLegacyCatIds(p.categories); + + const existing = db.prepare('SELECT id FROM plateformes WHERE nom = ? AND user_id = ?').get(p.nom, req.user.id); + let platId; + + const fields = [ + p.url || null, p.notes || null, + p.domiciliation || 'france', fiscalite, taux, + p.type_produit_fiscal || '2TT', p.methode_remboursement || 'portefeuille', + investisseurId, p.date_ouverture || null, + p.type_pret_defaut || null, p.freq_interets_defaut || null, + p.logo_filename || null, p.icone_filename || null, + ]; + + if (existing) { + db.prepare(` + UPDATE plateformes SET url=?, notes=?, domiciliation=?, fiscalite=?, taux_fiscalite_locale=?, + type_produit_fiscal=?, methode_remboursement=?, investisseur_id=?, date_ouverture=?, + type_pret_defaut=?, freq_interets_defaut=?, + logo_filename=?, icone_filename=? + WHERE id=? AND user_id=? + `).run(...fields, existing.id, req.user.id); + platId = existing.id; + updated++; + } else { + const r = db.prepare(` + INSERT INTO plateformes + (user_id, nom, url, notes, domiciliation, fiscalite, taux_fiscalite_locale, + type_produit_fiscal, methode_remboursement, investisseur_id, date_ouverture, + type_pret_defaut, freq_interets_defaut, + logo_filename, icone_filename, overridden_fields) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + `).run(req.user.id, p.nom, ...fields, '[]'); + platId = r.lastInsertRowid; + created++; + } + + // Sync catégories legacy + syncCategories(platId, legacyCatIds); + // Sync categories_inv + db.prepare('DELETE FROM plateforme_categories_inv WHERE plateforme_id = ?').run(platId); + for (const id of catsInvIds) db.prepare('INSERT OR IGNORE INTO plateforme_categories_inv (plateforme_id, categorie_id) VALUES (?,?)').run(platId, id); + // Sync secteurs_inv + db.prepare('DELETE FROM plateforme_secteurs_inv WHERE plateforme_id = ?').run(platId); + for (const id of sectsInvIds) db.prepare('INSERT OR IGNORE INTO plateforme_secteurs_inv (plateforme_id, secteur_id) VALUES (?,?)').run(platId, id); + + // Images + for (const fname of [p.logo_filename, p.icone_filename]) { + if (!fname || !imageMap[fname]) continue; + fs.writeFileSync(path.join(logosDir, fname), imageMap[fname]); + } + } + }); + tx(); + res.json({ ok: true, created, updated, total: platforms.length }); + } catch (e) { next(e); } +}); + + +router.put('/:id', (req, res, next) => { + try { + const body = Schema.parse(req.body); + const fiscalite = body.domiciliation === 'FR' ? 'flat_tax' : body.fiscalite; + const taux = fiscalite === 'avec_fiscalite_locale' ? (body.taux_fiscalite_locale ?? null) : null; + const typeProduitFiscal = body.domiciliation === 'FR' ? (body.type_produit_fiscal ?? '2TT') : '2TT'; + + // Récupérer l'état actuel pour calculer les overrides + const current = db.prepare('SELECT referentiel_id, overridden_fields, logo_filename FROM plateformes WHERE id = ? AND user_id = ?') + .get(req.params.id, req.user.id); + if (!current) throw new HttpError(404, 'Not found'); + + // Calculer overridden_fields si la plateforme est liée à un référentiel + let overriddenFields = JSON.parse(current.overridden_fields || '[]'); + if (current.referentiel_id) { + const ref = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(current.referentiel_id); + const newValues = { + nom: body.nom, + url: body.url || null, + domiciliation: body.domiciliation, + fiscalite, + taux_fiscalite_locale: taux, + type_produit_fiscal: typeProduitFiscal, + logo_filename: current.logo_filename, // logo géré séparément + methode_remboursement: body.methode_remboursement, + type_pret_defaut: body.type_pret_defaut ?? null, + freq_interets_defaut: body.freq_interets_defaut ?? null, + }; + overriddenFields = computeOverridden(ref, newValues, overriddenFields); + } + + const r = db.prepare(` + UPDATE plateformes + SET nom=?, url=?, notes=?, domiciliation=?, fiscalite=?, taux_fiscalite_locale=?, + type_produit_fiscal=?, methode_remboursement=?, investisseur_id=?, date_ouverture=?, + type_pret_defaut=?, freq_interets_defaut=?, + overridden_fields=? + WHERE id=? AND user_id=? + `).run( + body.nom, body.url || null, body.notes || null, body.domiciliation, fiscalite, taux, + typeProduitFiscal, body.methode_remboursement, body.investisseur_id ?? null, body.date_ouverture || null, + body.type_pret_defaut ?? null, body.freq_interets_defaut ?? null, + JSON.stringify(overriddenFields), + req.params.id, req.user.id + ); + if (r.changes === 0) throw new HttpError(404, 'Not found'); + syncCategories(Number(req.params.id), body.categories); + res.json({ + id: Number(req.params.id), ...body, + fiscalite, taux_fiscalite_locale: taux, type_produit_fiscal: typeProduitFiscal, + referentiel_id: current.referentiel_id, + overridden_fields: overriddenFields, + }); + } 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'); + 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'); + if (plat.logo_filename) { + const filePath = path.join(logosDir, plat.logo_filename); + fs.unlink(filePath, () => {}); // silencieux si déjà supprimé + } + res.status(204).end(); + } catch (e) { next(e); } +}); + +// ── Reset aux valeurs du référentiel ────────────────────────────────────── +router.post('/:id/reset', (req, res, next) => { + try { + const plat = db.prepare('SELECT * FROM plateformes WHERE id = ? AND user_id = ?') + .get(req.params.id, req.user.id); + if (!plat) throw new HttpError(404, 'Not found'); + if (!plat.referentiel_id) throw new HttpError(400, 'Cette plateforme n\'est liée à aucun référentiel'); + + const ref = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(plat.referentiel_id); + if (!ref) throw new HttpError(404, 'Référentiel introuvable'); + + db.prepare(` + UPDATE plateformes + SET nom=?, domiciliation=?, fiscalite=?, taux_fiscalite_locale=?, + type_produit_fiscal=?, logo_filename=?, 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, + req.params.id, req.user.id + ); + + const updated = db.prepare('SELECT * FROM plateformes WHERE id = ?').get(req.params.id); + res.json({ ...updated, overridden_fields: [] }); + } catch (e) { next(e); } +}); + +// ── Upload logo ─────────────────────────────────────────────────────────── +router.post('/:id/logo', upload.single('logo'), (req, res, next) => { + try { + if (!req.file) throw new HttpError(400, 'Aucun fichier reçu'); + + // Supprime l'ancien logo s'il diffère du nouveau + const old = db.prepare('SELECT logo_filename FROM plateformes WHERE id=? AND user_id=?') + .get(req.params.id, req.user.id); + if (old?.logo_filename && old.logo_filename !== req.file.filename) { + fs.unlink(path.join(logosDir, old.logo_filename), () => {}); + } + + db.prepare('UPDATE plateformes SET logo_filename=? WHERE id=? AND user_id=?') + .run(req.file.filename, req.params.id, req.user.id); + + res.json({ logo_filename: req.file.filename }); + } catch (e) { next(e); } +}); + +// ── Suppression logo ────────────────────────────────────────────────────── +router.delete('/:id/logo', (req, res, next) => { + try { + 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'); + if (plat.logo_filename) { + fs.unlink(path.join(logosDir, plat.logo_filename), () => {}); + db.prepare("UPDATE plateformes SET logo_filename=NULL WHERE id=? AND user_id=?") + .run(req.params.id, req.user.id); + } + res.status(204).end(); + } catch (e) { next(e); } +}); + +export default router; diff --git a/backend/src/routes/preferences.js b/backend/src/routes/preferences.js new file mode 100644 index 0000000..46a3b50 --- /dev/null +++ b/backend/src/routes/preferences.js @@ -0,0 +1,62 @@ +import { Router } from 'express'; +import db from '../db/index.js'; + +const router = Router(); + +// Clés autorisées — liste blanche pour éviter les injections de clés arbitraires +const ALLOWED_KEYS = new Set([ + 'chart_interets', + 'chart_capital', + 'chart_cashback', + // Extensible : ajouter ici les futures prefs (theme, font_scale, langue, devise, display_mode…) +]); + +/** + * GET /api/preferences + * Retourne toutes les préférences de l'utilisateur connecté sous forme d'objet plat. + * Les clés absentes ne sont pas retournées (le frontend utilise ses valeurs par défaut). + */ +router.get('/', (req, res) => { + const rows = db + .prepare('SELECT key, value FROM user_preferences WHERE user_id = ?') + .all(req.user.id); + + const result = {}; + for (const { key, value } of rows) result[key] = value; + + res.json(result); +}); + +/** + * PATCH /api/preferences + * Upsert d'un ou plusieurs couples { key: value }. + * Seules les clés de ALLOWED_KEYS sont acceptées. + */ +router.patch('/', (req, res) => { + const payload = req.body; + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + return res.status(400).json({ error: 'Corps invalide — objet { clé: valeur } attendu' }); + } + + const upsert = db.prepare(` + INSERT INTO user_preferences (user_id, key, value, updated_at) + VALUES (?, ?, ?, datetime('now')) + ON CONFLICT (user_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at + `); + + const unknown = Object.keys(payload).filter(k => !ALLOWED_KEYS.has(k)); + if (unknown.length) { + return res.status(400).json({ error: `Clés inconnues : ${unknown.join(', ')}` }); + } + + const persist = db.transaction(() => { + for (const [key, value] of Object.entries(payload)) { + upsert.run(req.user.id, key, String(value)); + } + }); + + persist(); + res.json({ ok: true }); +}); + +export default router; diff --git a/backend/src/routes/ref-categories.js b/backend/src/routes/ref-categories.js new file mode 100644 index 0000000..26b5735 --- /dev/null +++ b/backend/src/routes/ref-categories.js @@ -0,0 +1,97 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import db from '../db/index.js'; +import { HttpError } from '../middleware/errorHandler.js'; + +const router = Router(); +// Toutes ces routes sont montées sous requireAdmin dans server.js + +/* GET /api/ref-categories — liste complète */ +router.get('/', (_req, res) => { + const rows = db.prepare(` + SELECT c.id, c.nom, + COUNT(rc.referentiel_id) AS nb_utilises + FROM categories_inv c + LEFT JOIN referentiel_categories_inv rc ON rc.categorie_id = c.id + GROUP BY c.id + ORDER BY c.nom + `).all(); + res.json(rows); +}); + +/* POST /api/ref-categories — créer */ +router.post('/', (req, res, next) => { + try { + const { nom } = z.object({ nom: z.string().min(1).max(200) }).parse(req.body); + const exists = db.prepare('SELECT id FROM categories_inv WHERE nom = ?').get(nom.trim()); + if (exists) throw new HttpError(409, `La catégorie "${nom.trim()}" existe déjà.`); + const r = db.prepare('INSERT INTO categories_inv (nom) VALUES (?)').run(nom.trim()); + res.status(201).json({ id: r.lastInsertRowid, nom: nom.trim(), nb_utilises: 0 }); + } catch (e) { next(e); } +}); + +/* PUT /api/ref-categories/:id — renommer (propage automatiquement) */ +router.put('/:id', (req, res, next) => { + try { + const { nom } = z.object({ nom: z.string().min(1).max(200) }).parse(req.body); + const row = db.prepare('SELECT id, nom FROM categories_inv WHERE id = ?').get(req.params.id); + if (!row) throw new HttpError(404, 'Catégorie introuvable'); + const dup = db.prepare('SELECT id FROM categories_inv WHERE nom = ? AND id != ?').get(nom.trim(), row.id); + if (dup) throw new HttpError(409, `La catégorie "${nom.trim()}" existe déjà.`); + db.prepare('UPDATE categories_inv SET nom = ? WHERE id = ?').run(nom.trim(), row.id); + const nb = db.prepare('SELECT COUNT(*) AS n FROM referentiel_categories_inv WHERE categorie_id = ?').get(row.id).n; + res.json({ id: row.id, nom: nom.trim(), nb_utilises: nb }); + } catch (e) { next(e); } +}); + +/* DELETE /api/ref-categories/:id — supprimer si non utilisée */ +router.delete('/:id', (req, res, next) => { + try { + const row = db.prepare('SELECT id FROM categories_inv WHERE id = ?').get(req.params.id); + if (!row) throw new HttpError(404, 'Catégorie introuvable'); + const nb = db.prepare('SELECT COUNT(*) AS n FROM referentiel_categories_inv WHERE categorie_id = ?').get(row.id).n; + if (nb > 0) throw new HttpError(409, `Catégorie utilisée par ${nb} référentiel(s) — retirez-la d'abord.`); + db.prepare('DELETE FROM categories_inv WHERE id = ?').run(row.id); + res.status(204).end(); + } catch (e) { next(e); } +}); + +/* POST /api/ref-categories/:id/merge — fusionner dans une autre catégorie */ +router.post('/:id/merge', (req, res, next) => { + try { + const { target_id } = z.object({ target_id: z.number().int() }).parse(req.body); + const source = db.prepare('SELECT id, nom FROM categories_inv WHERE id = ?').get(req.params.id); + if (!source) throw new HttpError(404, 'Catégorie source introuvable'); + const target = db.prepare('SELECT id, nom FROM categories_inv WHERE id = ?').get(target_id); + if (!target) throw new HttpError(404, 'Catégorie cible introuvable'); + if (source.id === target.id) throw new HttpError(400, 'Source et cible identiques'); + + const result = db.transaction(() => { + const refs = db.prepare( + 'SELECT referentiel_id FROM referentiel_categories_inv WHERE categorie_id = ?' + ).all(source.id); + let added = 0, skipped = 0; + for (const { referentiel_id } of refs) { + const exists = db.prepare( + 'SELECT 1 FROM referentiel_categories_inv WHERE referentiel_id = ? AND categorie_id = ?' + ).get(referentiel_id, target.id); + if (!exists) { + db.prepare( + 'INSERT INTO referentiel_categories_inv (referentiel_id, categorie_id) VALUES (?, ?)' + ).run(referentiel_id, target.id); + added++; + } else { + skipped++; + } + } + db.prepare('DELETE FROM referentiel_categories_inv WHERE categorie_id = ?').run(source.id); + db.prepare('DELETE FROM categories_inv WHERE id = ?').run(source.id); + return { added, skipped, total: refs.length }; + })(); + + res.json({ ok: true, ...result, + message: `Fusion terminée : ${result.added} lien(s) ajouté(s), ${result.skipped} déjà présent(s). "${source.nom}" supprimée.` }); + } catch (e) { next(e); } +}); + +export default router; diff --git a/backend/src/routes/ref-secteurs.js b/backend/src/routes/ref-secteurs.js new file mode 100644 index 0000000..0a0219d --- /dev/null +++ b/backend/src/routes/ref-secteurs.js @@ -0,0 +1,97 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import db from '../db/index.js'; +import { HttpError } from '../middleware/errorHandler.js'; + +const router = Router(); +// Toutes ces routes sont montées sous requireAdmin dans server.js + +/* GET /api/ref-secteurs — liste complète */ +router.get('/', (_req, res) => { + const rows = db.prepare(` + SELECT s.id, s.nom, + COUNT(rs.referentiel_id) AS nb_utilises + FROM secteurs_inv s + LEFT JOIN referentiel_secteurs_inv rs ON rs.secteur_id = s.id + GROUP BY s.id + ORDER BY s.nom + `).all(); + res.json(rows); +}); + +/* POST /api/ref-secteurs — créer */ +router.post('/', (req, res, next) => { + try { + const { nom } = z.object({ nom: z.string().min(1).max(200) }).parse(req.body); + const exists = db.prepare('SELECT id FROM secteurs_inv WHERE nom = ?').get(nom.trim()); + if (exists) throw new HttpError(409, `Le secteur "${nom.trim()}" existe déjà.`); + const r = db.prepare('INSERT INTO secteurs_inv (nom) VALUES (?)').run(nom.trim()); + res.status(201).json({ id: r.lastInsertRowid, nom: nom.trim(), nb_utilises: 0 }); + } catch (e) { next(e); } +}); + +/* PUT /api/ref-secteurs/:id — renommer */ +router.put('/:id', (req, res, next) => { + try { + const { nom } = z.object({ nom: z.string().min(1).max(200) }).parse(req.body); + const row = db.prepare('SELECT id, nom FROM secteurs_inv WHERE id = ?').get(req.params.id); + if (!row) throw new HttpError(404, 'Secteur introuvable'); + const dup = db.prepare('SELECT id FROM secteurs_inv WHERE nom = ? AND id != ?').get(nom.trim(), row.id); + if (dup) throw new HttpError(409, `Le secteur "${nom.trim()}" existe déjà.`); + db.prepare('UPDATE secteurs_inv SET nom = ? WHERE id = ?').run(nom.trim(), row.id); + const nb = db.prepare('SELECT COUNT(*) AS n FROM referentiel_secteurs_inv WHERE secteur_id = ?').get(row.id).n; + res.json({ id: row.id, nom: nom.trim(), nb_utilises: nb }); + } catch (e) { next(e); } +}); + +/* DELETE /api/ref-secteurs/:id — supprimer si non utilisé */ +router.delete('/:id', (req, res, next) => { + try { + const row = db.prepare('SELECT id FROM secteurs_inv WHERE id = ?').get(req.params.id); + if (!row) throw new HttpError(404, 'Secteur introuvable'); + const nb = db.prepare('SELECT COUNT(*) AS n FROM referentiel_secteurs_inv WHERE secteur_id = ?').get(row.id).n; + if (nb > 0) throw new HttpError(409, `Secteur utilisé par ${nb} référentiel(s) — retirez-le d'abord.`); + db.prepare('DELETE FROM secteurs_inv WHERE id = ?').run(row.id); + res.status(204).end(); + } catch (e) { next(e); } +}); + +/* POST /api/ref-secteurs/:id/merge — fusionner dans un autre secteur */ +router.post('/:id/merge', (req, res, next) => { + try { + const { target_id } = z.object({ target_id: z.number().int() }).parse(req.body); + const source = db.prepare('SELECT id, nom FROM secteurs_inv WHERE id = ?').get(req.params.id); + if (!source) throw new HttpError(404, 'Secteur source introuvable'); + const target = db.prepare('SELECT id, nom FROM secteurs_inv WHERE id = ?').get(target_id); + if (!target) throw new HttpError(404, 'Secteur cible introuvable'); + if (source.id === target.id) throw new HttpError(400, 'Source et cible identiques'); + + const result = db.transaction(() => { + const refs = db.prepare( + 'SELECT referentiel_id FROM referentiel_secteurs_inv WHERE secteur_id = ?' + ).all(source.id); + let added = 0, skipped = 0; + for (const { referentiel_id } of refs) { + const exists = db.prepare( + 'SELECT 1 FROM referentiel_secteurs_inv WHERE referentiel_id = ? AND secteur_id = ?' + ).get(referentiel_id, target.id); + if (!exists) { + db.prepare( + 'INSERT INTO referentiel_secteurs_inv (referentiel_id, secteur_id) VALUES (?, ?)' + ).run(referentiel_id, target.id); + added++; + } else { + skipped++; + } + } + db.prepare('DELETE FROM referentiel_secteurs_inv WHERE secteur_id = ?').run(source.id); + db.prepare('DELETE FROM secteurs_inv WHERE id = ?').run(source.id); + return { added, skipped, total: refs.length }; + })(); + + res.json({ ok: true, ...result, + message: `Fusion terminée : ${result.added} lien(s) ajouté(s), ${result.skipped} déjà présent(s). "${source.nom}" supprimée.` }); + } catch (e) { next(e); } +}); + +export default router; diff --git a/backend/src/routes/referentiel.js b/backend/src/routes/referentiel.js new file mode 100644 index 0000000..6f89e8b --- /dev/null +++ b/backend/src/routes/referentiel.js @@ -0,0 +1,1006 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import multer from 'multer'; +import path from 'path'; +import fs from 'fs'; +import { fileURLToPath } from 'url'; +import sharp from 'sharp'; +import db from '../db/index.js'; +import { createZip, readZip } from '../utils/zip.js'; +import { HttpError } from '../middleware/errorHandler.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const logosDir = path.resolve(__dirname, '../../../data/logos'); +fs.mkdirSync(logosDir, { recursive: true }); + +// ── Traitement image (réutilisé depuis plateformes.js) ───────────────────── + +async function processLogoFile(filePath) { + const ext = path.extname(filePath).toLowerCase(); + if (ext === '.svg') { + try { + let svg = fs.readFileSync(filePath, 'utf8'); + // Supprimer fond blanc SVG (background-color, rect blanc, etc.) + svg = svg.replace(/(]*)\sstyle="([^"]*)"/i, (_, tag, style) => { + const cleaned = style.split(';').filter(s => !/^\s*background(-color)?\s*:/i.test(s)).join(';').replace(/^;+|;+$/g, ''); + return cleaned ? `${tag} style="${cleaned}"` : tag; + }); + svg = svg.replace(/\s+enable-background="[^"]*"/gi, ''); + fs.writeFileSync(filePath, svg, 'utf8'); + } catch (e) { console.warn('[ref-logos] SVG cleanup failed:', e.message); } + return filePath; + } + if (['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) { + const pngPath = filePath.replace(/\.(jpg|jpeg|webp|png)$/i, '.png'); + try { + const { data, info } = await sharp(filePath).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); + for (let i = 0; i < data.length; i += 4) { + if (data[i] > 240 && data[i + 1] > 240 && data[i + 2] > 240) data[i + 3] = 0; + } + await sharp(data, { raw: { width: info.width, height: info.height, channels: 4 } }) + .png({ compressionLevel: 8 }).toFile(pngPath); + if (pngPath !== filePath) fs.unlinkSync(filePath); + return pngPath; + } catch (e) { console.warn('[ref-logos] sharp failed:', e.message); return filePath; } + } + return filePath; +} + +// ── Multer ────────────────────────────────────────────────────────────────── +const storage = multer.diskStorage({ + destination: (_req, _file, cb) => cb(null, logosDir), + filename: (req, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase() || '.png'; + cb(null, `ref_${req.params.id}_${Date.now()}${ext}`); + }, +}); +const upload = multer({ + storage, + limits: { fileSize: 2 * 1024 * 1024 }, + fileFilter: (_req, file, cb) => { + const allowed = ['.svg', '.png', '.jpg', '.jpeg', '.webp']; + if (allowed.includes(path.extname(file.originalname).toLowerCase())) cb(null, true); + else cb(new Error('Format non supporté — SVG, PNG, JPG ou WebP uniquement')); + }, +}); + +const router = Router(); +// Toutes ces routes sont montées sous requireAdmin dans server.js + +const Schema = z.object({ + nom: z.string().min(1).max(200), + url: z.string().url().optional().or(z.literal('')).nullable(), + domiciliation: z.string().min(1).max(100).default('france'), + fiscalite: z.enum(['flat_tax', 'sans_fiscalite_locale', 'avec_fiscalite_locale']).default('flat_tax'), + taux_fiscalite_locale: z.number().min(0).max(100).nullable().optional(), + type_produit_fiscal: z.enum(['2TT', '2TR']).default('2TT'), + methode_remboursement: z.enum(['portefeuille', 'compte_courant', 'choix_investisseur']).optional().default('portefeuille'), + type_pret_defaut: z.enum(['in_fine', 'amortissable', 'differe']).nullable().optional(), + freq_interets_defaut: z.enum(['mensuel', 'trimestriel', 'in_fine']).nullable().optional(), + logo_filename: z.string().nullable().optional(), + description: z.string().nullable().optional(), + // Profil enrichi + annee_creation: z.number().int().nullable().optional(), + investisseurs_types: z.string().nullable().optional(), + regulateur: z.string().nullable().optional(), + numero_licence: z.string().nullable().optional(), + is_regule: z.union([z.boolean(), z.number()]).optional().default(false), + pays_inscription: z.string().nullable().optional(), + pays_siege: z.string().nullable().optional(), + pays_operation: z.array(z.string()).optional().default([]), + investissement_minimum: z.number().nullable().optional(), + rendement_annonce: z.number().nullable().optional(), + nb_investisseurs: z.number().int().nullable().optional(), + volume_total_finance: z.number().nullable().optional(), + duree_moyenne_pret: z.number().nullable().optional(), + garantie_rachat: z.union([z.boolean(), z.number()]).optional().default(false), + statistiques_publiques: z.union([z.boolean(), z.number()]).optional().default(false), + bonus_inscription: z.union([z.boolean(), z.number()]).optional().default(false), + marche_secondaire: z.union([z.boolean(), z.number()]).optional().default(false), + investissement_auto: z.union([z.boolean(), z.number()]).optional().default(false), + url_trustpilot: z.string().url().optional().or(z.literal('')).nullable(), + url_linkedin: z.string().url().optional().or(z.literal('')).nullable(), + // Catégories : tableau de noms (strings) — indépendant des catégories user (legacy) + categories: z.array(z.string().min(1)).optional().default([]), + // Nouvelles listes gérées globalement (IDs) + categories_inv_ids: z.array(z.number().int()).optional().default([]), + secteurs_inv_ids: z.array(z.number().int()).optional().default([]), + // Notation : tableau de critères + notation: z.array(z.object({ + nom: z.string().min(1), + type: z.enum(['etoiles', 'lettres', 'score', 'custom']).default('etoiles'), + valeurs: z.array(z.string()).nullable().optional(), + min_val: z.number().nullable().optional(), + max_val: z.number().nullable().optional(), + description: z.string().nullable().optional(), + ordre: z.number().int().default(0), + })).optional().default([]), +}); + +// ── Helpers ──────────────────────────────────────────────────────────────── + +/** Catégories attachées au référentiel (stockées comme strings dans referentiel_categories.nom) */ +function attachCats(rows) { + if (rows.length === 0) return rows; + const ids = rows.map(r => r.id); + const cats = db.prepare(` + SELECT rc.referentiel_id, rc.categorie_nom + FROM referentiel_categories rc + WHERE rc.referentiel_id IN (${ids.map(() => '?').join(',')}) + ORDER BY rc.categorie_nom + `).all(...ids); + + const map = {}; + for (const c of cats) { + if (!map[c.referentiel_id]) map[c.referentiel_id] = []; + map[c.referentiel_id].push(c.categorie_nom); + } + return rows.map(r => ({ ...r, categories: map[r.id] || [] })); +} + +/** Catégories d'investissement globales (categories_inv) */ +function attachCatsInv(rows) { + if (rows.length === 0) return rows; + const ids = rows.map(r => r.id); + const links = db.prepare(` + SELECT rc.referentiel_id, c.id AS categorie_id, c.nom + FROM referentiel_categories_inv rc + JOIN categories_inv c ON c.id = rc.categorie_id + WHERE rc.referentiel_id IN (${ids.map(() => '?').join(',')}) + ORDER BY c.nom + `).all(...ids); + const map = {}; + for (const l of links) { + if (!map[l.referentiel_id]) map[l.referentiel_id] = []; + map[l.referentiel_id].push({ id: l.categorie_id, nom: l.nom }); + } + return rows.map(r => ({ ...r, categories_inv: map[r.id] || [] })); +} + +/** Secteurs d'investissement globaux (secteurs_inv) */ +function attachSecteursInv(rows) { + if (rows.length === 0) return rows; + const ids = rows.map(r => r.id); + const links = db.prepare(` + SELECT rs.referentiel_id, s.id AS secteur_id, s.nom + FROM referentiel_secteurs_inv rs + JOIN secteurs_inv s ON s.id = rs.secteur_id + WHERE rs.referentiel_id IN (${ids.map(() => '?').join(',')}) + ORDER BY s.nom + `).all(...ids); + const map = {}; + for (const l of links) { + if (!map[l.referentiel_id]) map[l.referentiel_id] = []; + map[l.referentiel_id].push({ id: l.secteur_id, nom: l.nom }); + } + return rows.map(r => ({ ...r, secteurs_inv: map[r.id] || [] })); +} + +/** Critères de notation du référentiel */ +function attachNotation(rows) { + if (rows.length === 0) return rows; + const ids = rows.map(r => r.id); + const notations = db.prepare(` + SELECT * FROM referentiel_notation + WHERE referentiel_id IN (${ids.map(() => '?').join(',')}) + ORDER BY referentiel_id, ordre, id + `).all(...ids); + + const map = {}; + for (const n of notations) { + if (!map[n.referentiel_id]) map[n.referentiel_id] = []; + map[n.referentiel_id].push({ + ...n, + valeurs: n.valeurs ? JSON.parse(n.valeurs) : null, + }); + } + return rows.map(r => ({ ...r, notation: map[r.id] || [] })); +} + +/** Parse pays_operation (JSON TEXT → array) */ +function parsePaysOp(rows) { + return rows.map(r => ({ + ...r, + pays_operation: r.pays_operation + ? (typeof r.pays_operation === 'string' ? JSON.parse(r.pays_operation) : r.pays_operation) + : [], + })); +} + +/** Enregistre catégories, secteurs et notation pour un référentiel */ +function saveRelations(tx, refId, categories, notation, categoriesInvIds, secteursInvIds) { + tx(() => { + // Legacy string categories + db.prepare('DELETE FROM referentiel_categories WHERE referentiel_id = ?').run(refId); + if (categories.length > 0) { + const ins = db.prepare( + 'INSERT OR IGNORE INTO referentiel_categories (referentiel_id, categorie_nom) VALUES (?, ?)' + ); + for (const nom of categories) ins.run(refId, nom.trim()); + } + + // Nouvelles catégories globales (IDs) + db.prepare('DELETE FROM referentiel_categories_inv WHERE referentiel_id = ?').run(refId); + if (categoriesInvIds && categoriesInvIds.length > 0) { + const ins = db.prepare('INSERT OR IGNORE INTO referentiel_categories_inv (referentiel_id, categorie_id) VALUES (?,?)'); + for (const id of categoriesInvIds) ins.run(refId, id); + } + + // Secteurs globaux (IDs) + db.prepare('DELETE FROM referentiel_secteurs_inv WHERE referentiel_id = ?').run(refId); + if (secteursInvIds && secteursInvIds.length > 0) { + const ins = db.prepare('INSERT OR IGNORE INTO referentiel_secteurs_inv (referentiel_id, secteur_id) VALUES (?,?)'); + for (const id of secteursInvIds) ins.run(refId, id); + } + + db.prepare('DELETE FROM referentiel_notation WHERE referentiel_id = ?').run(refId); + if (notation.length > 0) { + const ins = db.prepare(` + INSERT INTO referentiel_notation (referentiel_id, nom, type, valeurs, min_val, max_val, description, ordre) + VALUES (?,?,?,?,?,?,?,?) + `); + for (const n of notation) { + ins.run( + refId, n.nom, n.type || 'etoiles', + (n.valeurs && n.valeurs.length) ? JSON.stringify(n.valeurs) : null, + n.min_val ?? null, n.max_val ?? null, + n.description ?? null, n.ordre ?? 0 + ); + } + } + }); +} + +// ── GET /api/referentiel ─────────────────────────────────────────────────── +router.get('/', (_req, res) => { + let rows = db.prepare(` + SELECT pr.*, + (SELECT COUNT(*) FROM plateformes p WHERE p.referentiel_id = pr.id) AS nb_plateformes_liees + FROM plateformes_referentiel pr + ORDER BY pr.nom + `).all(); + rows = attachCats(rows); + rows = attachCatsInv(rows); + rows = attachSecteursInv(rows); + rows = attachNotation(rows); + rows = parsePaysOp(rows); + res.json(rows); +}); + +// ── Multer for ZIP uploads ───────────────────────────────────────────────── +const zipUpload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 50 * 1024 * 1024 }, + fileFilter: (_req, file, cb) => { + if (file.mimetype === 'application/zip' || file.originalname.endsWith('.zip')) cb(null, true); + else cb(new HttpError(400, 'Fichier ZIP attendu')); + }, +}); + +// ── GET /api/referentiel/export — export tout le référentiel ─────────────── +router.get('/export', (_req, res, next) => { + try { + let rows = db.prepare('SELECT * FROM plateformes_referentiel ORDER BY nom').all(); + rows = attachCatsInv(rows); + rows = attachSecteursInv(rows); + rows = parsePaysOp(rows); + + const entries = []; + const manifest = { + version: '1.0', + app: 'crowdlending', + exported_at: new Date().toISOString(), + count: rows.length, + type: 'referentiel', + }; + entries.push({ name: 'manifest.json', data: JSON.stringify(manifest, null, 2) }); + + const dataRows = rows.map(r => ({ + nom: r.nom, + url: r.url, + domiciliation: r.domiciliation, + fiscalite: r.fiscalite, + taux_fiscalite_locale: r.taux_fiscalite_locale, + type_produit_fiscal: r.type_produit_fiscal, + description: r.description, + annee_creation: r.annee_creation, + investisseurs_types: r.investisseurs_types, + regulateur: r.regulateur, + numero_licence: r.numero_licence, + is_regule: r.is_regule, + pays_inscription: r.pays_inscription, + pays_siege: r.pays_siege, + pays_operation: r.pays_operation || [], + investissement_minimum: r.investissement_minimum, + rendement_annonce: r.rendement_annonce, + nb_investisseurs: r.nb_investisseurs, + volume_total_finance: r.volume_total_finance, + duree_moyenne_pret: r.duree_moyenne_pret, + garantie_rachat: r.garantie_rachat, + statistiques_publiques: r.statistiques_publiques, + bonus_inscription: r.bonus_inscription, + marche_secondaire: r.marche_secondaire, + investissement_auto: r.investissement_auto, + url_trustpilot: r.url_trustpilot, + url_linkedin: r.url_linkedin, + methode_remboursement: r.methode_remboursement, + type_pret_defaut: r.type_pret_defaut, + freq_interets_defaut: r.freq_interets_defaut, + logo_filename: r.logo_filename, + icone_filename: r.icone_filename, + categories_inv: (r.categories_inv || []).map(c => c.nom), + secteurs_inv: (r.secteurs_inv || []).map(s => s.nom), + })); + + entries.push({ name: 'data.json', data: JSON.stringify(dataRows, null, 2) }); + + // Include logo / icon files + for (const r of rows) { + for (const fname of [r.logo_filename, r.icone_filename]) { + if (!fname) continue; + const fpath = path.join(logosDir, fname); + if (fs.existsSync(fpath)) { + entries.push({ name: `logos/${fname}`, data: fs.readFileSync(fpath) }); + } + } + } + + const zipBuf = createZip(entries); + const slug = 'referentiel-' + new Date().toISOString().slice(0, 10); + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', `attachment; filename="${slug}.zip"`); + res.send(zipBuf); + } catch (e) { next(e); } +}); + +// ── GET /api/referentiel/:id/export — export une seule entrée ─────────────── +router.get('/:id/export', (req, res, next) => { + try { + const ref = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(req.params.id); + if (!ref) throw new HttpError(404, 'Référentiel introuvable'); + const [enriched] = parsePaysOp(attachSecteursInv(attachCatsInv([ref]))); + + const entries = []; + const manifest = { + version: '1.0', + app: 'crowdlending', + exported_at: new Date().toISOString(), + count: 1, + type: 'referentiel', + }; + entries.push({ name: 'manifest.json', data: JSON.stringify(manifest, null, 2) }); + + const dataRow = { + nom: enriched.nom, + url: enriched.url, + domiciliation: enriched.domiciliation, + fiscalite: enriched.fiscalite, + taux_fiscalite_locale: enriched.taux_fiscalite_locale, + type_produit_fiscal: enriched.type_produit_fiscal, + description: enriched.description, + annee_creation: enriched.annee_creation, + investisseurs_types: enriched.investisseurs_types, + regulateur: enriched.regulateur, + numero_licence: enriched.numero_licence, + is_regule: enriched.is_regule, + pays_inscription: enriched.pays_inscription, + pays_siege: enriched.pays_siege, + pays_operation: enriched.pays_operation || [], + investissement_minimum: enriched.investissement_minimum, + rendement_annonce: enriched.rendement_annonce, + nb_investisseurs: enriched.nb_investisseurs, + volume_total_finance: enriched.volume_total_finance, + duree_moyenne_pret: enriched.duree_moyenne_pret, + garantie_rachat: enriched.garantie_rachat, + statistiques_publiques: enriched.statistiques_publiques, + bonus_inscription: enriched.bonus_inscription, + marche_secondaire: enriched.marche_secondaire, + investissement_auto: enriched.investissement_auto, + url_trustpilot: enriched.url_trustpilot, + url_linkedin: enriched.url_linkedin, + methode_remboursement: enriched.methode_remboursement, + type_pret_defaut: enriched.type_pret_defaut, + freq_interets_defaut: enriched.freq_interets_defaut, + logo_filename: enriched.logo_filename, + icone_filename: enriched.icone_filename, + categories_inv: (enriched.categories_inv || []).map(c => c.nom), + secteurs_inv: (enriched.secteurs_inv || []).map(s => s.nom), + }; + + entries.push({ name: 'data.json', data: JSON.stringify([dataRow], null, 2) }); + + for (const fname of [enriched.logo_filename, enriched.icone_filename]) { + if (!fname) continue; + const fpath = path.join(logosDir, fname); + if (fs.existsSync(fpath)) { + entries.push({ name: `logos/${fname}`, data: fs.readFileSync(fpath) }); + } + } + + const zipBuf = createZip(entries); + const slug = enriched.nom.toLowerCase().replace(/[^a-z0-9]+/g, '-') + '-' + new Date().toISOString().slice(0, 10); + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', `attachment; filename="${slug}.zip"`); + res.send(zipBuf); + } catch (e) { next(e); } +}); + +// ── POST /api/referentiel/import-zip — import depuis un fichier ZIP ───────── +router.post('/import-zip', zipUpload.single('file'), async (req, res, next) => { + try { + if (!req.file) throw new HttpError(400, 'Fichier ZIP manquant'); + const zipEntries = readZip(req.file.buffer); + + const manifestEntry = zipEntries.find(e => e.name === 'manifest.json'); + const dataEntry = zipEntries.find(e => e.name === 'data.json'); + if (!dataEntry) throw new HttpError(400, 'ZIP invalide : data.json manquant'); + + const platforms = JSON.parse(dataEntry.data.toString('utf8')); + if (!Array.isArray(platforms)) throw new HttpError(400, 'data.json doit être un tableau'); + + // Normalise tag names to IDs (create global if missing) + function resolveTagIds(names, table) { + const ids = []; + for (const nom of (names || [])) { + const trimmed = nom.trim(); + if (!trimmed) continue; + let row = db.prepare(`SELECT id FROM ${table} WHERE nom = ? AND user_id IS NULL`).get(trimmed); + if (!row) { + const r = db.prepare(`INSERT INTO ${table} (nom, user_id) VALUES (?, NULL)`).run(trimmed); + row = { id: r.lastInsertRowid }; + } + ids.push(row.id); + } + return ids; + } + + // Build a map of images from the ZIP + const imageMap = {}; + for (const e of zipEntries) { + if (e.name.startsWith('logos/') && e.name.length > 6) { + imageMap[path.basename(e.name)] = e.data; + } + } + + let created = 0; + let updated = 0; + const tx = db.transaction(() => { + for (const p of platforms) { + if (!p.nom) continue; + + const catsIds = resolveTagIds(p.categories_inv, 'categories_inv'); + const sectsIds = resolveTagIds(p.secteurs_inv, 'secteurs_inv'); + const paysOp = Array.isArray(p.pays_operation) && p.pays_operation.length ? JSON.stringify(p.pays_operation) : null; + + const existing = db.prepare('SELECT id FROM plateformes_referentiel WHERE nom = ?').get(p.nom); + + const fields = [ + p.url || null, p.domiciliation || 'france', p.fiscalite || 'flat_tax', + p.taux_fiscalite_locale ?? null, p.type_produit_fiscal || '2TT', + p.description ?? null, p.annee_creation ?? null, + p.investisseurs_types ?? null, + p.regulateur ?? null, p.numero_licence ?? null, p.is_regule ? 1 : 0, + p.pays_inscription ?? null, p.pays_siege ?? null, paysOp, + p.investissement_minimum ?? null, p.rendement_annonce ?? null, + p.nb_investisseurs ?? null, p.volume_total_finance ?? null, p.duree_moyenne_pret ?? null, + p.garantie_rachat ? 1 : 0, p.statistiques_publiques ? 1 : 0, + p.bonus_inscription ? 1 : 0, p.marche_secondaire ? 1 : 0, p.investissement_auto ? 1 : 0, + p.url_trustpilot || null, p.url_linkedin || null, + p.logo_filename ?? null, p.icone_filename ?? null, + ]; + + let refId; + if (existing) { + db.prepare(` + UPDATE plateformes_referentiel SET + url=?, domiciliation=?, fiscalite=?, taux_fiscalite_locale=?, type_produit_fiscal=?, + description=?, annee_creation=?, investisseurs_types=?, + regulateur=?, numero_licence=?, is_regule=?, + pays_inscription=?, pays_siege=?, pays_operation=?, + investissement_minimum=?, rendement_annonce=?, + nb_investisseurs=?, volume_total_finance=?, duree_moyenne_pret=?, + garantie_rachat=?, statistiques_publiques=?, bonus_inscription=?, marche_secondaire=?, investissement_auto=?, + url_trustpilot=?, url_linkedin=?, + logo_filename=?, icone_filename=?, + updated_at=datetime('now') + WHERE id=? + `).run(...fields, existing.id); + refId = existing.id; + updated++; + } else { + const r = db.prepare(` + INSERT INTO plateformes_referentiel + (nom, url, domiciliation, fiscalite, taux_fiscalite_locale, type_produit_fiscal, + description, annee_creation, investisseurs_types, + regulateur, numero_licence, is_regule, + pays_inscription, pays_siege, pays_operation, + investissement_minimum, rendement_annonce, + nb_investisseurs, volume_total_finance, duree_moyenne_pret, + garantie_rachat, statistiques_publiques, bonus_inscription, marche_secondaire, investissement_auto, + url_trustpilot, url_linkedin, + logo_filename, icone_filename, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now')) + `).run(p.nom, ...fields); + refId = r.lastInsertRowid; + created++; + } + + // Sync categories_inv + db.prepare('DELETE FROM referentiel_categories_inv WHERE referentiel_id = ?').run(refId); + for (const id of catsIds) { + db.prepare('INSERT OR IGNORE INTO referentiel_categories_inv (referentiel_id, categorie_id) VALUES (?,?)').run(refId, id); + } + // Sync secteurs_inv + db.prepare('DELETE FROM referentiel_secteurs_inv WHERE referentiel_id = ?').run(refId); + for (const id of sectsIds) { + db.prepare('INSERT OR IGNORE INTO referentiel_secteurs_inv (referentiel_id, secteur_id) VALUES (?,?)').run(refId, id); + } + + // Write logo / icon images from ZIP + for (const fname of [p.logo_filename, p.icone_filename]) { + if (!fname || !imageMap[fname]) continue; + const dest = path.join(logosDir, fname); + fs.writeFileSync(dest, imageMap[fname]); + } + } + }); + + tx(); + res.json({ ok: true, created, updated, total: platforms.length }); + } catch (e) { next(e); } +}); + + +// ── GET /api/referentiel/:id ─────────────────────────────────────────────── +router.get('/:id', (req, res, next) => { + try { + const row = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(req.params.id); + if (!row) throw new HttpError(404, 'Référentiel introuvable'); + const [enriched] = parsePaysOp(attachNotation(attachSecteursInv(attachCatsInv(attachCats([row]))))); + res.json(enriched); + } catch (e) { next(e); } +}); + +// ── POST /api/referentiel ────────────────────────────────────────────────── +router.post('/', (req, res, next) => { + try { + const data = Schema.parse(req.body); + const existing = db.prepare('SELECT id FROM plateformes_referentiel WHERE nom = ?').get(data.nom); + if (existing) throw new HttpError(409, `Une entrée référentiel "${data.nom}" existe déjà`); + + const r = db.prepare(` + INSERT INTO plateformes_referentiel + (nom, url, domiciliation, fiscalite, taux_fiscalite_locale, type_produit_fiscal, + methode_remboursement, type_pret_defaut, freq_interets_defaut, + logo_filename, description, + annee_creation, investisseurs_types, + regulateur, numero_licence, is_regule, + pays_inscription, pays_siege, pays_operation, + investissement_minimum, rendement_annonce, nb_investisseurs, volume_total_finance, duree_moyenne_pret, + garantie_rachat, statistiques_publiques, bonus_inscription, marche_secondaire, investissement_auto, + url_trustpilot, url_linkedin, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, datetime('now')) + `).run( + data.nom, data.url || null, data.domiciliation, data.fiscalite, + data.taux_fiscalite_locale ?? null, data.type_produit_fiscal, + data.methode_remboursement || 'portefeuille', data.type_pret_defaut ?? null, data.freq_interets_defaut ?? null, + data.logo_filename ?? null, data.description ?? null, + data.annee_creation ?? null, data.investisseurs_types ?? null, + data.regulateur ?? null, data.numero_licence ?? null, data.is_regule ? 1 : 0, + data.pays_inscription ?? null, data.pays_siege ?? null, + data.pays_operation?.length ? JSON.stringify(data.pays_operation) : null, + data.investissement_minimum ?? null, data.rendement_annonce ?? null, + data.nb_investisseurs ?? null, data.volume_total_finance ?? null, data.duree_moyenne_pret ?? null, + data.garantie_rachat ? 1 : 0, data.statistiques_publiques ? 1 : 0, + data.bonus_inscription ? 1 : 0, data.marche_secondaire ? 1 : 0, data.investissement_auto ? 1 : 0, + data.url_trustpilot || null, data.url_linkedin || null + ); + + const refId = r.lastInsertRowid; + const tx = db.transaction(fn => fn()); + saveRelations(tx, refId, data.categories, data.notation, data.categories_inv_ids, data.secteurs_inv_ids); + + const row = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(refId); + const [enriched] = parsePaysOp(attachNotation(attachSecteursInv(attachCatsInv(attachCats([row]))))); + enriched.nb_plateformes_liees = 0; + res.status(201).json(enriched); + } catch (e) { next(e); } +}); + +// ── PUT /api/referentiel/:id ─────────────────────────────────────────────── +router.put('/:id', (req, res, next) => { + try { + const row = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(req.params.id); + if (!row) throw new HttpError(404, 'Référentiel introuvable'); + + const data = Schema.parse(req.body); + + // Vérifier unicité du nom si changé + if (data.nom !== row.nom) { + const dup = db.prepare('SELECT id FROM plateformes_referentiel WHERE nom = ? AND id != ?').get(data.nom, row.id); + if (dup) throw new HttpError(409, `Une entrée référentiel "${data.nom}" existe déjà`); + } + + db.prepare(` + UPDATE plateformes_referentiel + SET nom=?, url=?, domiciliation=?, fiscalite=?, taux_fiscalite_locale=?, + type_produit_fiscal=?, methode_remboursement=?, type_pret_defaut=?, freq_interets_defaut=?, + logo_filename=?, description=?, + annee_creation=?, investisseurs_types=?, + regulateur=?, numero_licence=?, is_regule=?, + pays_inscription=?, pays_siege=?, pays_operation=?, + investissement_minimum=?, rendement_annonce=?, nb_investisseurs=?, + volume_total_finance=?, duree_moyenne_pret=?, + garantie_rachat=?, statistiques_publiques=?, bonus_inscription=?, + marche_secondaire=?, investissement_auto=?, + url_trustpilot=?, url_linkedin=?, updated_at=datetime('now') + WHERE id=? + `).run( + data.nom, data.url || null, data.domiciliation, data.fiscalite, + data.taux_fiscalite_locale ?? null, data.type_produit_fiscal, + data.methode_remboursement || 'portefeuille', data.type_pret_defaut ?? null, data.freq_interets_defaut ?? null, + data.logo_filename ?? null, data.description ?? null, + data.annee_creation ?? null, data.investisseurs_types ?? null, + data.regulateur ?? null, data.numero_licence ?? null, data.is_regule ? 1 : 0, + data.pays_inscription ?? null, data.pays_siege ?? null, + data.pays_operation?.length ? JSON.stringify(data.pays_operation) : null, + data.investissement_minimum ?? null, data.rendement_annonce ?? null, + data.nb_investisseurs ?? null, data.volume_total_finance ?? null, data.duree_moyenne_pret ?? null, + data.garantie_rachat ? 1 : 0, data.statistiques_publiques ? 1 : 0, + data.bonus_inscription ? 1 : 0, data.marche_secondaire ? 1 : 0, data.investissement_auto ? 1 : 0, + data.url_trustpilot || null, data.url_linkedin || null, row.id + ); + + const tx = db.transaction(fn => fn()); + saveRelations(tx, row.id, data.categories, data.notation, data.categories_inv_ids, data.secteurs_inv_ids); + + const updated = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(row.id); + const nb = db.prepare('SELECT COUNT(*) AS n FROM plateformes WHERE referentiel_id = ?').get(row.id).n; + const [enriched] = parsePaysOp(attachNotation(attachSecteursInv(attachCatsInv(attachCats([updated]))))); + enriched.nb_plateformes_liees = nb; + res.json(enriched); + } catch (e) { next(e); } +}); + +// ── DELETE /api/referentiel/:id ──────────────────────────────────────────── +// Délie les plateformes liées (referentiel_id → null) avant suppression +router.delete('/:id', (req, res, next) => { + try { + const row = db.prepare('SELECT id FROM plateformes_referentiel WHERE id = ?').get(req.params.id); + if (!row) throw new HttpError(404, 'Référentiel introuvable'); + + db.transaction(() => { + // Délier les plateformes (on ne supprime pas les données user) + db.prepare('UPDATE plateformes SET referentiel_id = NULL WHERE referentiel_id = ?').run(row.id); + db.prepare('DELETE FROM plateformes_referentiel WHERE id = ?').run(row.id); + })(); + + res.json({ deleted: true }); + } catch (e) { next(e); } +}); + +// ── GET /api/referentiel/:id/plateformes ─────────────────────────────────── +// Liste les plateformes user liées à ce référentiel +router.get('/:id/plateformes', (req, res, next) => { + try { + const row = db.prepare('SELECT id FROM plateformes_referentiel WHERE id = ?').get(req.params.id); + if (!row) throw new HttpError(404, 'Référentiel introuvable'); + + const plateformes = db.prepare(` + SELECT p.id, p.nom, p.domiciliation, p.fiscalite, p.overridden_fields, + u.email AS user_email, + inv.nom AS detenteur_nom, inv.prenom AS detenteur_prenom + FROM plateformes p + JOIN users u ON u.id = p.user_id + LEFT JOIN investisseurs inv ON inv.id = p.investisseur_id + WHERE p.referentiel_id = ? + ORDER BY u.email, p.nom + `).all(req.params.id); + + res.json(plateformes.map(p => ({ + ...p, + overridden_fields: JSON.parse(p.overridden_fields || '[]'), + }))); + } catch (e) { next(e); } +}); + +// ── POST /api/referentiel/:id/push ───────────────────────────────────────── +// Pousse les champs du référentiel vers les plateformes liées. +// Body { force: true } → écrase tous les champs (méthode dure), y compris les overrides utilisateur. +// Défaut (force absent ou false) → respecte overridden_fields (méthode douce). +router.post('/:id/push', (req, res, next) => { + try { + const ref = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(req.params.id); + if (!ref) throw new HttpError(404, 'Référentiel introuvable'); + + const force = req.body?.force === true; + + // Champs scalaires poussables + const PUSHABLE = ['nom', 'url', 'domiciliation', 'fiscalite', 'taux_fiscalite_locale', 'type_produit_fiscal', 'logo_filename', 'methode_remboursement', 'type_pret_defaut', 'freq_interets_defaut']; + + // Catégories/secteurs du référentiel + const refCatIds = db.prepare( + 'SELECT categorie_id FROM referentiel_categories_inv WHERE referentiel_id = ?' + ).all(ref.id).map(r => r.categorie_id); + const refSectIds = db.prepare( + 'SELECT secteur_id FROM referentiel_secteurs_inv WHERE referentiel_id = ?' + ).all(ref.id).map(r => r.secteur_id); + + const plateformes = db.prepare( + 'SELECT id, overridden_fields FROM plateformes WHERE referentiel_id = ?' + ).all(ref.id); + + let nb_updated = 0; + + db.transaction(() => { + const insCat = db.prepare('INSERT OR IGNORE INTO plateforme_categories_inv (plateforme_id, categorie_id) VALUES (?, ?)'); + const insSect = db.prepare('INSERT OR IGNORE INTO plateforme_secteurs_inv (plateforme_id, secteur_id) VALUES (?, ?)'); + const insInvCat = db.prepare('INSERT OR IGNORE INTO investissement_categories_inv (investissement_id, categorie_id) VALUES (?, ?)'); + const insInvSect = db.prepare('INSERT OR IGNORE INTO investissement_secteurs_inv (investissement_id, secteur_id) VALUES (?, ?)'); + + for (const plat of plateformes) { + // ── Champs scalaires ── + let fields; + if (force) { + fields = PUSHABLE; + } else { + const overridden = JSON.parse(plat.overridden_fields || '[]'); + fields = PUSHABLE.filter(f => !overridden.includes(f)); + } + if (fields.length > 0) { + const setParts = fields.map(f => `${f} = ?`).join(', '); + const values = fields.map(f => ref[f] ?? null); + const extraSet = force ? ', overridden_fields = \'[]\'' : ''; + db.prepare(`UPDATE plateformes SET ${setParts}${extraSet} WHERE id = ?`).run(...values, plat.id); + } + + // ── Catégories/secteurs ── + 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); + } + // 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); + + // Sync investissements actifs de la plateforme + const invs = db.prepare( + "SELECT id FROM investissements WHERE plateforme_id = ? AND statut NOT IN ('rembourse','cloture')" + ).all(plat.id); + for (const inv of invs) { + if (force) { + 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); + } + + nb_updated++; + } + })(); + + res.json({ pushed: true, force, nb_plateformes: plateformes.length, nb_updated }); + } catch (e) { next(e); } +}); + +// ── POST /api/referentiel/:id/logo ───────────────────────────────────────── +// Upload d'un logo pour une entrée du référentiel ; auto-push vers les plateformes liées +router.post('/:id/logo', upload.single('file'), async (req, res, next) => { + try { + const ref = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(req.params.id); + if (!ref) throw new HttpError(404, 'Référentiel introuvable'); + if (!req.file) throw new HttpError(400, 'Fichier requis'); + + // Traiter l'image (fond blanc transparent, conversion en PNG si besoin) + const finalPath = await processLogoFile(req.file.path); + const filename = path.basename(finalPath); + + // Supprimer l'ancien logo s'il existe + if (ref.logo_filename) { + const old = path.join(logosDir, ref.logo_filename); + if (fs.existsSync(old)) fs.unlink(old, () => {}); + } + + // Mettre à jour le référentiel + db.prepare(`UPDATE plateformes_referentiel SET logo_filename=?, updated_at=datetime('now') WHERE id=?`) + .run(filename, ref.id); + + // Auto-push logo_filename vers les plateformes liées (sauf si overridé) + const plateformes = db.prepare('SELECT id, overridden_fields FROM plateformes WHERE referentiel_id = ?').all(ref.id); + db.transaction(() => { + for (const plat of plateformes) { + const overridden = JSON.parse(plat.overridden_fields || '[]'); + if (!overridden.includes('logo_filename')) { + db.prepare('UPDATE plateformes SET logo_filename=? WHERE id=?').run(filename, plat.id); + } + } + })(); + + res.json({ + logo_filename: filename, + nb_plateformes_updated: plateformes.filter(p => !JSON.parse(p.overridden_fields || '[]').includes('logo_filename')).length + }); + } catch (e) { next(e); } +}); + +// ── DELETE /api/referentiel/:id/logo ─────────────────────────────────────── +router.delete('/:id/logo', async (req, res, next) => { + try { + const ref = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(req.params.id); + if (!ref) throw new HttpError(404, 'Référentiel introuvable'); + + if (ref.logo_filename) { + const filePath = path.join(logosDir, ref.logo_filename); + if (fs.existsSync(filePath)) fs.unlink(filePath, () => {}); + } + + db.prepare(`UPDATE plateformes_referentiel SET logo_filename=NULL, updated_at=datetime('now') WHERE id=?`).run(ref.id); + + // Pousser null vers les plateformes liées non-overridées + const plateformes = db.prepare('SELECT id, overridden_fields FROM plateformes WHERE referentiel_id = ?').all(ref.id); + db.transaction(() => { + for (const plat of plateformes) { + const overridden = JSON.parse(plat.overridden_fields || '[]'); + if (!overridden.includes('logo_filename')) { + db.prepare('UPDATE plateformes SET logo_filename=NULL WHERE id=?').run(plat.id); + } + } + })(); + + res.json({ deleted: true }); + } catch (e) { next(e); } +}); + +// ── POST /api/referentiel/:id/icone ────────────────────────────────────────── +// Upload d'une icone pour une entree du referentiel ; auto-push vers les plateformes liees +router.post('/:id/icone', upload.single('file'), async (req, res, next) => { + try { + const ref = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(req.params.id); + if (!ref) throw new HttpError(404, 'Referentiel introuvable'); + if (!req.file) throw new HttpError(400, 'Fichier requis'); + + const finalPath = await processLogoFile(req.file.path); + const filename = path.basename(finalPath); + + if (ref.icone_filename) { + const oldFile = path.join(logosDir, ref.icone_filename); + if (fs.existsSync(oldFile)) fs.unlink(oldFile, () => {}); + } + + db.prepare(`UPDATE plateformes_referentiel SET icone_filename=?, updated_at=datetime('now') WHERE id=?`) + .run(filename, ref.id); + + // Auto-push icone_filename vers les plateformes liées (sauf si overridé) + const plateformesIcn = db.prepare('SELECT id, overridden_fields FROM plateformes WHERE referentiel_id = ?').all(ref.id); + db.transaction(() => { + for (const plat of plateformesIcn) { + const overridden = JSON.parse(plat.overridden_fields || '[]'); + if (!overridden.includes('icone_filename')) { + db.prepare('UPDATE plateformes SET icone_filename=? WHERE id=?').run(filename, plat.id); + } + } + })(); + + res.json({ + icone_filename: filename, + nb_plateformes_updated: plateformesIcn.filter(p => !JSON.parse(p.overridden_fields || '[]').includes('icone_filename')).length + }); + } catch (e) { next(e); } +}); + +// ── DELETE /api/referentiel/:id/icone ───────────────────────────────────────── +router.delete('/:id/icone', async (req, res, next) => { + try { + const ref = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(req.params.id); + if (!ref) throw new HttpError(404, 'Referentiel introuvable'); + + if (ref.icone_filename) { + const filePath = path.join(logosDir, ref.icone_filename); + if (fs.existsSync(filePath)) fs.unlink(filePath, () => {}); + } + + db.prepare(`UPDATE plateformes_referentiel SET icone_filename=NULL, updated_at=datetime('now') WHERE id=?`).run(ref.id); + + const plateformesIcn = db.prepare('SELECT id, overridden_fields FROM plateformes WHERE referentiel_id = ?').all(ref.id); + db.transaction(() => { + for (const plat of plateformesIcn) { + const overridden = JSON.parse(plat.overridden_fields || '[]'); + if (!overridden.includes('icone_filename')) { + db.prepare('UPDATE plateformes SET icone_filename=NULL WHERE id=?').run(plat.id); + } + } + })(); + + res.json({ deleted: true }); + } catch (e) { next(e); } +}); + + +// -- NOTATION -- criteres par referentiel ----------------------------------------- + +const TYPES_VALIDES_NOT = ['etoiles', 'lettres', 'score', 'custom']; + +function enrichNot(row) { + return { ...row, valeurs: row.valeurs ? JSON.parse(row.valeurs) : null }; +} + +// GET /api/referentiel/:id/notation +router.get('/:id/notation', (req, res, next) => { + try { + const ref = db.prepare('SELECT id FROM plateformes_referentiel WHERE id = ?').get(req.params.id); + if (!ref) throw new HttpError(404, 'Referentiel introuvable'); + const rows = db.prepare( + 'SELECT * FROM referentiel_notation WHERE referentiel_id = ? ORDER BY ordre, id' + ).all(ref.id); + res.json(rows.map(enrichNot)); + } catch (e) { next(e); } +}); + +// POST /api/referentiel/:id/notation +router.post('/:id/notation', (req, res, next) => { + try { + const ref = db.prepare('SELECT id FROM plateformes_referentiel WHERE id = ?').get(req.params.id); + if (!ref) throw new HttpError(404, 'Referentiel introuvable'); + + const { nom, type = 'etoiles', valeurs, min_val, max_val, description, ordre } = req.body || {}; + if (!nom?.trim()) throw new HttpError(400, 'nom est requis'); + if (!TYPES_VALIDES_NOT.includes(type)) throw new HttpError(400, 'type invalide'); + + const r = db.prepare(` + INSERT INTO referentiel_notation (referentiel_id, nom, type, valeurs, min_val, max_val, description, ordre) + VALUES (?,?,?,?,?,?,?,?) + `).run( + ref.id, nom.trim(), type, + (type === 'lettres' || type === 'custom') + ? JSON.stringify(Array.isArray(valeurs) ? valeurs : String(valeurs || '').split(',').map(v => v.trim()).filter(Boolean)) + : null, + type === 'score' ? Number(min_val ?? 0) : null, + type === 'score' ? Number(max_val ?? 10) : null, + description?.trim() || null, + Number(ordre ?? 0), + ); + res.status(201).json(enrichNot(db.prepare('SELECT * FROM referentiel_notation WHERE id = ?').get(r.lastInsertRowid))); + } catch (e) { next(e); } +}); + +// PUT /api/referentiel/notation/:notationId +router.put('/notation/:notationId', (req, res, next) => { + try { + const existing = db.prepare('SELECT * FROM referentiel_notation WHERE id = ?').get(req.params.notationId); + if (!existing) throw new HttpError(404, 'Critere introuvable'); + + const { nom, type, valeurs, min_val, max_val, description, ordre } = req.body || {}; + const t = type || existing.type; + if (!nom?.trim()) throw new HttpError(400, 'nom est requis'); + + db.prepare(` + UPDATE referentiel_notation SET nom=?, type=?, valeurs=?, min_val=?, max_val=?, description=?, ordre=? + WHERE id=? + `).run( + nom.trim(), t, + (t === 'lettres' || t === 'custom') + ? JSON.stringify(Array.isArray(valeurs) ? valeurs : String(valeurs || '').split(',').map(v => v.trim()).filter(Boolean)) + : null, + t === 'score' ? Number(min_val ?? 0) : null, + t === 'score' ? Number(max_val ?? 10) : null, + description?.trim() || null, + Number(ordre ?? 0), + req.params.notationId, + ); + res.json(enrichNot(db.prepare('SELECT * FROM referentiel_notation WHERE id = ?').get(req.params.notationId))); + } catch (e) { next(e); } +}); + +// DELETE /api/referentiel/notation/:notationId +router.delete('/notation/:notationId', (req, res, next) => { + try { + const existing = db.prepare('SELECT id FROM referentiel_notation WHERE id = ?').get(req.params.notationId); + if (!existing) throw new HttpError(404, 'Critere introuvable'); + db.prepare('DELETE FROM referentiel_notation WHERE id = ?').run(req.params.notationId); + res.json({ deleted: true }); + } catch (e) { next(e); } +}); + +export default router; diff --git a/backend/src/routes/referentielPublic.js b/backend/src/routes/referentielPublic.js new file mode 100644 index 0000000..39739eb --- /dev/null +++ b/backend/src/routes/referentielPublic.js @@ -0,0 +1,60 @@ +import { Router } from 'express'; +import db from '../db/index.js'; +import { HttpError } from '../middleware/errorHandler.js'; + +const router = Router(); +// Monté sous requireAuth (sans requireAdmin) — lecture seule pour tous les users + +function attachCats(rows) { + if (rows.length === 0) return rows; + const ids = rows.map(r => r.id); + const cats = db.prepare(` + SELECT rc.referentiel_id, rc.categorie_nom + FROM referentiel_categories rc + WHERE rc.referentiel_id IN (${ids.map(() => '?').join(',')}) + ORDER BY rc.categorie_nom + `).all(...ids); + const map = {}; + for (const c of cats) { + if (!map[c.referentiel_id]) map[c.referentiel_id] = []; + map[c.referentiel_id].push(c.categorie_nom); + } + return rows.map(r => ({ ...r, categories: map[r.id] || [] })); +} + +function attachNotation(rows) { + if (rows.length === 0) return rows; + const ids = rows.map(r => r.id); + const notations = db.prepare(` + SELECT * FROM referentiel_notation + WHERE referentiel_id IN (${ids.map(() => '?').join(',')}) + ORDER BY referentiel_id, ordre, id + `).all(...ids); + const map = {}; + for (const n of notations) { + if (!map[n.referentiel_id]) map[n.referentiel_id] = []; + map[n.referentiel_id].push({ ...n, valeurs: n.valeurs ? JSON.parse(n.valeurs) : null }); + } + return rows.map(r => ({ ...r, notation: map[r.id] || [] })); +} + +function parsePaysOp(rows) { + return rows.map(r => ({ + ...r, + pays_operation: r.pays_operation + ? (typeof r.pays_operation === 'string' ? JSON.parse(r.pays_operation) : r.pays_operation) + : [], + })); +} + +// ── GET /api/referentiel-public/:id ─────────────────────────────────────────── +router.get('/:id', (req, res, next) => { + try { + const row = db.prepare('SELECT * FROM plateformes_referentiel WHERE id = ?').get(req.params.id); + if (!row) throw new HttpError(404, 'Référentiel introuvable'); + const [enriched] = parsePaysOp(attachNotation(attachCats([row]))); + res.json(enriched); + } catch (e) { next(e); } +}); + +export default router; diff --git a/backend/src/routes/reinvestissements.js b/backend/src/routes/reinvestissements.js new file mode 100644 index 0000000..3ab5eb3 --- /dev/null +++ b/backend/src/routes/reinvestissements.js @@ -0,0 +1,89 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import db from '../db/index.js'; +import { HttpError } from '../middleware/errorHandler.js'; +import { requireInvestisseur } from '../middleware/investisseurScope.js'; +import { generateSimulWithReinvestissements } from '../utils/schedule.js'; + +const router = Router(); +router.use(requireInvestisseur); + +const Schema = z.object({ + investissement_id: z.number().int().positive(), + montant: z.number().positive(), + date_reinvestissement: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + note: z.string().optional().nullable(), +}); + +/** Vérifie que l'investissement appartient bien à l'utilisateur connecté. */ +function checkOwnership(investissementId, userId) { + const row = db.prepare(` + SELECT i.id FROM investissements i + JOIN investisseurs inv ON inv.id = i.investisseur_id AND inv.user_id = ? + WHERE i.id = ? + `).get(userId, investissementId); + if (!row) throw new HttpError(404, 'Investissement introuvable'); + return row; +} + +// GET /api/reinvestissements?investissement_id=X (un investissement) +// GET /api/reinvestissements?scope=all (tous les investissements de l'utilisateur) +router.get('/', (req, res, next) => { + try { + if (req.query.scope === 'all') { + const rows = db.prepare(` + SELECT rv.* + FROM reinvestissements rv + JOIN investissements i ON i.id = rv.investissement_id + JOIN investisseurs inv ON inv.id = i.investisseur_id AND inv.user_id = ? + ORDER BY rv.date_reinvestissement + `).all(req.user.id); + return res.json(rows); + } + + const invId = Number(req.query.investissement_id); + if (!invId) throw new HttpError(400, 'investissement_id requis'); + checkOwnership(invId, req.user.id); + + const rows = db.prepare( + 'SELECT * FROM reinvestissements WHERE investissement_id = ? ORDER BY date_reinvestissement' + ).all(invId); + res.json(rows); + } catch (e) { next(e); } +}); + +// POST /api/reinvestissements +router.post('/', (req, res, next) => { + try { + const body = Schema.parse(req.body); + checkOwnership(body.investissement_id, req.user.id); + + const r = db.prepare(` + INSERT INTO reinvestissements (investissement_id, montant, date_reinvestissement, note) + VALUES (?, ?, ?, ?) + `).run(body.investissement_id, body.montant, body.date_reinvestissement, body.note || null); + + // Régénérer le tableau de simulation en tenant compte du réinvestissement + generateSimulWithReinvestissements(db, body.investissement_id); + + res.status(201).json({ id: r.lastInsertRowid, ...body }); + } catch (e) { next(e); } +}); + +// DELETE /api/reinvestissements/:id +router.delete('/:id', (req, res, next) => { + try { + const row = db.prepare('SELECT * FROM reinvestissements WHERE id = ?').get(req.params.id); + if (!row) throw new HttpError(404, 'Réinvestissement introuvable'); + checkOwnership(row.investissement_id, req.user.id); + + db.prepare('DELETE FROM reinvestissements WHERE id = ?').run(req.params.id); + + // Régénérer le tableau de simulation sans ce réinvestissement + generateSimulWithReinvestissements(db, row.investissement_id); + + res.status(204).end(); + } catch (e) { next(e); } +}); + +export default router; diff --git a/backend/src/routes/remboursements.js b/backend/src/routes/remboursements.js new file mode 100644 index 0000000..7220d41 --- /dev/null +++ b/backend/src/routes/remboursements.js @@ -0,0 +1,467 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import db from '../db/index.js'; +import { HttpError } from '../middleware/errorHandler.js'; +import { requireInvestisseur } from '../middleware/investisseurScope.js'; +import { adjustSimulForActuals, generateSimulWithReinvestissements } from '../utils/schedule.js'; + +const router = Router(); + +const Schema = z.object({ + type: z.enum(['normal', 'bonus_parrainage', 'bonus_plateforme']).default('normal'), + investissement_id: z.number().int().positive().nullable().optional(), + bonus_plateforme_id: z.number().int().positive().nullable().optional(), + bonus_investisseur_id: z.number().int().positive().nullable().optional(), + date_remb: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + capital: z.number().nonnegative().default(0), + cashback: z.number().nonnegative().default(0), + interets_bruts_avant_local: z.number().nonnegative().default(0), + taxe_locale: z.number().nonnegative().default(0), + interets_bruts: z.number().nonnegative().default(0), + prelev_sociaux: z.number().nonnegative().default(0), + prelev_forfaitaire: z.number().nonnegative().default(0), + statut: z.enum(['paye','retard','partiel','impaye']).default('paye'), + notes: z.string().optional(), + methode_remboursement: z.enum(['portefeuille', 'compte_courant']).default('portefeuille'), + compte_id: z.number().int().positive().nullable().optional(), +}).refine(data => { + if (data.type === 'normal') return !!data.investissement_id; + return !!data.bonus_plateforme_id; +}, { message: 'investissement_id requis pour type normal ; bonus_plateforme_id requis pour les bonus' }); + +/** + * isTaxIndicatif = true → plateforme étrangère ou investissement exonéré PEA-PME : + * les prélèvements sont stockés à titre indicatif, mais le montant réellement + * versé (net_recu) n'en tient pas compte (capital + cashback + interets_bruts). + * isTaxIndicatif = false → plateforme flat_tax non exonérée : + * les prélèvements sont retenus à la source, net_recu = capital + cashback + interets_nets. + */ +function computeChamps(body, isTaxIndicatif = false) { + const interets_nets = Math.round((body.interets_bruts - body.prelev_sociaux - body.prelev_forfaitaire) * 100) / 100; + const net_recu = isTaxIndicatif + ? Math.round(((body.capital || 0) + (body.cashback || 0) + (body.interets_bruts || 0)) * 100) / 100 + : Math.round(((body.capital || 0) + (body.cashback || 0) + interets_nets) * 100) / 100; + return { interets_nets, net_recu }; +} + +function getTaxIndicatif(investissementId) { + if (!investissementId) return false; + const row = db.prepare(` + SELECT p.fiscalite, i.fiscalite_override + FROM investissements i JOIN plateformes p ON p.id = i.plateforme_id + WHERE i.id = ? + `).get(investissementId); + if (!row) return false; + return row.fiscalite !== 'flat_tax' || row.fiscalite_override === 'exonere'; +} + +/** + * Crée ou supprime le retrait automatique lié à un remboursement. + * Appelé après chaque POST/PUT sur un remboursement normal. + * - Si methode === 'compte_courant' : supprime l'ancien retrait lié (s'il existe) + * puis en insère un nouveau avec source='auto_remboursement'. + * - Sinon : supprime simplement l'ancien retrait lié. + */ +function syncAutoRetrait(rembId, methode, netRecu, investissementId, dateRemb) { + // Suppression du retrait automatique précédent (s'il y en avait un) + db.prepare('DELETE FROM depots_retraits WHERE remboursement_id = ?').run(rembId); + + if (methode !== 'compte_courant') return; + + const inv = db.prepare( + 'SELECT investisseur_id, plateforme_id, nom_projet FROM investissements WHERE id = ?' + ).get(investissementId); + if (!inv) return; + + db.prepare(` + INSERT INTO depots_retraits + (investisseur_id, plateforme_id, date_operation, type, montant, + libelle, source, remboursement_id) + VALUES (?, ?, ?, 'retrait', ?, ?, 'auto_remboursement', ?) + `).run( + inv.investisseur_id, inv.plateforme_id, dateRemb, + netRecu, + `Remboursement — ${inv.nom_projet}`, + rembId, + ); +} + +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; + const { total_capital } = db.prepare( + 'SELECT COALESCE(SUM(capital), 0) AS total_capital FROM remboursements WHERE investissement_id=?' + ).get(investissement_id); + const { reinvTotal } = db.prepare( + 'SELECT COALESCE(SUM(montant), 0) AS reinvTotal FROM reinvestissements WHERE investissement_id=?' + ).get(investissement_id); + const capitalTotal = inv.montant_investi + (reinvTotal || 0); + const restant = Math.round((capitalTotal - total_capital) * 100) / 100; + const newStatut = restant <= 0 ? 'rembourse' : 'en_cours'; + db.prepare(` + UPDATE investissements + SET statut = ?, updated_at = datetime('now') + WHERE id = ? AND statut IN ('en_cours', 'rembourse') + `).run(newStatut, investissement_id); +} + +router.use(requireInvestisseur); + +function assertOwnedInvestissement(invId, userId) { + const row = db.prepare(` + SELECT i.id FROM investissements i + JOIN investisseurs inv ON inv.id = i.investisseur_id AND inv.user_id = ? + WHERE i.id = ? + `).get(userId, invId); + if (!row) throw new HttpError(403, 'Investissement not in scope'); +} + +function assertOwnedInvestisseur(invId, userId) { + const row = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?').get(invId, userId); + if (!row) throw new HttpError(403, 'Investisseur not in scope'); +} + +router.get('/', (req, res) => { + const scopeAll = req.query.scope === 'all'; + const { from, to, plateforme_id, investissement_id } = req.query; + + const invCond = scopeAll + ? `(i.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?) + OR (r.investissement_id IS NULL AND r.bonus_investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)))` + : `(i.investisseur_id = ? + OR (r.investissement_id IS NULL AND r.bonus_investisseur_id = ?))`; + const invArg = scopeAll ? req.user.id : req.investisseur.id; + + const outerConds = []; + const outerArgs = []; + if (from) { outerConds.push('date_remb >= ?'); outerArgs.push(from); } + if (to) { outerConds.push('date_remb <= ?'); outerArgs.push(to); } + if (plateforme_id) { outerConds.push('plateforme_id = ?'); outerArgs.push(Number(plateforme_id)); } + if (investissement_id){ outerConds.push('investissement_id = ?'); outerArgs.push(Number(investissement_id)); } + + const outerWhere = outerConds.length ? `WHERE ${outerConds.join(' AND ')}` : ''; + + const rows = db.prepare(` + WITH base AS ( + SELECT r.*, + COALESCE(i.plateforme_id, r.bonus_plateforme_id) AS plateforme_id, + COALESCE(i.investisseur_id, r.bonus_investisseur_id) AS resolved_investisseur_id, + i.nom_projet AS inv_nom_projet, + CASE WHEN r.investissement_id IS NOT NULL + THEN i.montant_investi - ( + SELECT COALESCE(SUM(r2.capital), 0) + FROM remboursements r2 + WHERE r2.investissement_id = r.investissement_id + AND (r2.date_remb < r.date_remb OR (r2.date_remb = r.date_remb AND r2.id <= r.id)) + ) + ELSE 0 + END AS capital_restant_du + FROM remboursements r + LEFT JOIN investissements i ON i.id = r.investissement_id + WHERE ${invCond} + ) + SELECT + b.*, + CASE b.type + WHEN 'normal' THEN b.inv_nom_projet + WHEN 'bonus_parrainage' THEN '— Bonus Parrainage' + WHEN 'bonus_plateforme' THEN '— Bonus Plateforme' + ELSE b.inv_nom_projet + END AS nom_projet, + p.nom AS plateforme_nom, + inv.nom AS investisseur_nom, + plat_inv.nom AS plateforme_detenteur_nom, + c.nom AS compte_nom + FROM base b + LEFT JOIN plateformes p ON p.id = b.plateforme_id + LEFT JOIN investisseurs plat_inv ON plat_inv.id = p.investisseur_id + LEFT JOIN investisseurs inv ON inv.id = b.resolved_investisseur_id + LEFT JOIN comptes c ON c.id = b.compte_id + ${outerWhere} + ORDER BY b.date_remb DESC, b.id DESC + `).all(invArg, invArg, ...outerArgs); + + res.json(rows); +}); + +router.post('/', (req, res, next) => { + try { + const body = Schema.parse(req.body); + const { interets_nets, net_recu } = computeChamps(body, getTaxIndicatif(body.investissement_id)); + + if (body.type === 'normal') { + assertOwnedInvestissement(body.investissement_id, req.user.id); + const r = db.prepare(` + INSERT INTO remboursements + (type, investissement_id, date_remb, capital, cashback, + interets_bruts_avant_local, taxe_locale, interets_bruts, prelev_sociaux, + prelev_forfaitaire, interets_nets, net_recu, statut, source, notes, methode_remboursement, compte_id) + VALUES ('normal',?,?,?,?,?,?,?,?,?,?,?,?, 'manuel', ?, ?, ?) + `).run( + body.investissement_id, body.date_remb, body.capital, body.cashback, + body.interets_bruts_avant_local, body.taxe_locale, + body.interets_bruts, body.prelev_sociaux, body.prelev_forfaitaire, + interets_nets, net_recu, body.statut, body.notes || null, body.methode_remboursement, + body.methode_remboursement === 'compte_courant' ? (body.compte_id ?? null) : null, + ); + syncInvestissementStatut(body.investissement_id); + adjustSimulForActuals(db, body.investissement_id); + syncAutoRetrait(r.lastInsertRowid, body.methode_remboursement, net_recu, body.investissement_id, body.date_remb); + // ── Réinvestissement automatique des intérêts ────────────────────────── + { + const inv = db.prepare( + 'SELECT i.auto_reinvest, p.fiscalite FROM investissements i JOIN plateformes p ON p.id = i.plateforme_id WHERE i.id = ?' + ).get(body.investissement_id); + if (inv?.auto_reinvest) { + // Plateforme française (flat_tax) : utiliser les intérêts nets (prélevés à la source) + // Autres plateformes : utiliser les intérêts bruts (rien n'est retenu) + const montantAuto = inv.fiscalite === 'flat_tax' ? interets_nets : body.interets_bruts; + if (montantAuto > 0) { + db.prepare(` + INSERT INTO reinvestissements (investissement_id, montant, date_reinvestissement, note, source) + VALUES (?, ?, ?, 'Réinvestissement automatique des intérêts', 'auto') + `).run(body.investissement_id, montantAuto, body.date_remb); + generateSimulWithReinvestissements(db, body.investissement_id); + } + } + } + return res.status(201).json({ id: r.lastInsertRowid, ...body, interets_nets, net_recu }); + } + + // Bonus (parrainage ou plateforme) + const bonusInvestisseurId = body.bonus_investisseur_id ?? req.investisseur.id; + assertOwnedInvestisseur(bonusInvestisseurId, req.user.id); + const r = db.prepare(` + INSERT INTO remboursements + (type, bonus_plateforme_id, bonus_investisseur_id, date_remb, + capital, cashback, interets_bruts, prelev_sociaux, prelev_forfaitaire, + interets_nets, net_recu, statut, source, notes) + VALUES (?,?,?,?, 0,?,0,0,0, 0,?, ?, 'manuel', ?) + `).run( + body.type, body.bonus_plateforme_id, bonusInvestisseurId, body.date_remb, + body.cashback, body.cashback, body.statut, body.notes || null, + ); + res.status(201).json({ id: r.lastInsertRowid, ...body, interets_nets: 0, net_recu: body.cashback }); + } catch (e) { next(e); } +}); + +/* ── Retraitement fiscal en masse ───────────────────────────── */ +router.post('/reprocess', (req, res, next) => { + try { + const round2 = v => Math.round(v * 100) / 100; + + const rembs = db.prepare(` + SELECT r.id, r.investissement_id, r.capital, r.cashback, r.date_remb, + r.interets_bruts_avant_local, r.taxe_locale, r.interets_bruts, + p.fiscalite, p.taux_fiscalite_locale, + i.fiscalite_override + FROM remboursements r + JOIN investissements i ON i.id = r.investissement_id + JOIN plateformes p ON p.id = i.plateforme_id + JOIN investisseurs inv ON inv.id = i.investisseur_id + WHERE r.type = 'normal' + AND r.investissement_id IS NOT NULL + AND inv.user_id = ? + `).all(req.user.id); + + const pfuRates = db.prepare('SELECT * FROM taux_pfu').all(); + const getRates = (dateStr) => { + const year = parseInt(dateStr.substring(0, 4), 10); + return pfuRates.find(r => r.annee === year) ?? null; + }; + + const stmtUpdate = db.prepare(` + UPDATE remboursements + SET taxe_locale=?, interets_bruts=?, prelev_sociaux=?, prelev_forfaitaire=?, + interets_nets=?, net_recu=? + WHERE id=? + `); + + const stmtUpdateRetrait = db.prepare(` + UPDATE depots_retraits SET montant=? + WHERE remboursement_id=? AND source='auto_remboursement' + `); + + let updated = 0; + const affectedInvIds = new Set(); + + db.transaction(() => { + for (const r of rembs) { + const rates = getRates(r.date_remb); + let taxe_locale = 0; + let interets_bruts = r.interets_bruts; + let prelev_sociaux = 0; + let prelev_forfaitaire = 0; + + if (r.fiscalite === 'avec_fiscalite_locale' && r.taux_fiscalite_locale) { + // Base = interets avant taxe locale (fallback sur interets_bruts si colonne vide) + const base = (r.interets_bruts_avant_local > 0) + ? r.interets_bruts_avant_local + : r.interets_bruts; + taxe_locale = round2(base * r.taux_fiscalite_locale / 100); + interets_bruts = round2(base - taxe_locale); + } + + // Prélèvements calculés pour TOUTES les plateformes (indicatif pour les non-flat_tax) + if (rates) { + prelev_sociaux = round2(interets_bruts * rates.prelev_sociaux / 100); + prelev_forfaitaire = round2(interets_bruts * rates.impot_revenu / 100); + } + + const interets_nets = round2(interets_bruts - prelev_sociaux - prelev_forfaitaire); + // Plateforme étrangère ou investissement exonéré : le montant versé n'inclut pas + // les prélèvements indicatifs — l'investisseur reçoit le brut intégral. + const isTaxIndicatif = r.fiscalite !== 'flat_tax' || r.fiscalite_override === 'exonere'; + const net_recu = isTaxIndicatif + ? round2((r.capital || 0) + (r.cashback || 0) + interets_bruts) + : round2((r.capital || 0) + (r.cashback || 0) + interets_nets); + + stmtUpdate.run(taxe_locale, interets_bruts, prelev_sociaux, prelev_forfaitaire, + interets_nets, net_recu, r.id); + stmtUpdateRetrait.run(net_recu, r.id); + affectedInvIds.add(r.investissement_id); + updated++; + } + })(); + + // Régénérer l'échéancier pour chaque investissement impacté + for (const invId of affectedInvIds) { + adjustSimulForActuals(db, invId); + } + + res.json({ updated }); + } catch (e) { next(e); } +}); + +/* ── Modifier un remboursement ──────────────────────────────── */ +router.put('/:id', (req, res, next) => { + try { + const id = Number(req.params.id); + const existing = db.prepare(` + SELECT r.id, r.type, r.investissement_id, r.bonus_plateforme_id, r.bonus_investisseur_id + FROM remboursements r + LEFT JOIN investissements i ON i.id = r.investissement_id + LEFT JOIN investisseurs inv ON inv.id = COALESCE(i.investisseur_id, r.bonus_investisseur_id) + WHERE r.id = ? AND inv.user_id = ? + `).get(id, req.user.id); + if (!existing) throw new HttpError(404, 'Not found'); + + const body = Schema.parse(req.body); + const { interets_nets, net_recu } = computeChamps(body, getTaxIndicatif(body.investissement_id)); + + if (body.type === 'normal') { + db.prepare(` + UPDATE remboursements + SET date_remb=?, capital=?, cashback=?, + interets_bruts_avant_local=?, taxe_locale=?, interets_bruts=?, + prelev_sociaux=?, prelev_forfaitaire=?, + interets_nets=?, net_recu=?, statut=?, notes=?, methode_remboursement=?, compte_id=? + WHERE id=? + `).run( + body.date_remb, body.capital, body.cashback, + body.interets_bruts_avant_local, body.taxe_locale, body.interets_bruts, + body.prelev_sociaux, body.prelev_forfaitaire, + interets_nets, net_recu, body.statut, body.notes || null, body.methode_remboursement, + body.methode_remboursement === 'compte_courant' ? (body.compte_id ?? null) : null, + id, + ); + syncInvestissementStatut(body.investissement_id); + adjustSimulForActuals(db, body.investissement_id); + syncAutoRetrait(id, body.methode_remboursement, net_recu, body.investissement_id, body.date_remb); + } else { + const bonusInvestisseurId = body.bonus_investisseur_id ?? existing.bonus_investisseur_id; + db.prepare(` + UPDATE remboursements + SET date_remb=?, cashback=?, net_recu=?, statut=?, notes=? + WHERE id=? + `).run(body.date_remb, body.cashback, body.cashback, body.statut, body.notes || null, id); + } + + res.json({ id, ...body, interets_nets, net_recu }); + } catch (e) { next(e); } +}); + +/* ── Supprimer un remboursement ─────────────────────────────── */ +router.delete('/:id', (req, res, next) => { + try { + const id = Number(req.params.id); + const existing = db.prepare(` + SELECT r.id, r.investissement_id + FROM remboursements r + LEFT JOIN investissements i ON i.id = r.investissement_id + LEFT JOIN investisseurs inv ON inv.id = COALESCE(i.investisseur_id, r.bonus_investisseur_id) + WHERE r.id = ? AND inv.user_id = ? + `).get(id, req.user.id); + if (!existing) throw new HttpError(404, 'Not found'); + + syncAutoRetrait(id, 'portefeuille', 0, null, null); // supprime le retrait auto si présent + db.prepare('DELETE FROM remboursements WHERE id=?').run(id); + if (existing.investissement_id) { + syncInvestissementStatut(existing.investissement_id); + adjustSimulForActuals(db, existing.investissement_id); + } + res.json({ deleted: id }); + } catch (e) { next(e); } +}); + +/* ── Supprimer tous les remboursements d'un investissement ───── */ +router.delete('/', (req, res, next) => { + try { + const investissement_id = Number(req.query.investissement_id); + if (!investissement_id) throw new HttpError(400, 'investissement_id requis'); + assertOwnedInvestissement(investissement_id, req.user.id); + + const rembs = db.prepare('SELECT id FROM remboursements WHERE investissement_id=?').all(investissement_id); + for (const r of rembs) { + syncAutoRetrait(r.id, 'portefeuille', 0, null, null); + } + db.prepare('DELETE FROM remboursements WHERE investissement_id=?').run(investissement_id); + syncInvestissementStatut(investissement_id); + adjustSimulForActuals(db, investissement_id); + + res.json({ deleted: rembs.length }); + } catch (e) { next(e); } +}); + +// ── Backfill compte_id sur les remboursements ────────────────────────────── +// Pour chaque remboursement methode=compte_courant sans compte_id : +// 1. Utilise le compte_id de l'investissement lié si disponible +// 2. Sinon prend le premier compte de type compte_courant du détenteur +router.post('/backfill-comptes', (req, res, next) => { + try { + const rows = db.prepare(` + SELECT r.id AS remb_id, i.compte_id AS inv_compte_id, i.investisseur_id + FROM remboursements r + JOIN investissements i ON i.id = r.investissement_id + JOIN investisseurs inv ON inv.id = i.investisseur_id AND inv.user_id = ? + WHERE r.methode_remboursement = 'compte_courant' + AND r.compte_id IS NULL + AND r.type = 'normal' + `).all(req.user.id); + + let updated = 0; + const stmt = db.prepare('UPDATE remboursements SET compte_id=? WHERE id=?'); + + for (const row of rows) { + let compteId = row.inv_compte_id ?? null; + + // Fallback : premier compte_courant du détenteur + if (!compteId) { + const compte = db.prepare( + "SELECT id FROM comptes WHERE investisseur_id=? AND user_id=? AND type='compte_courant' ORDER BY id LIMIT 1" + ).get(row.investisseur_id, req.user.id); + compteId = compte?.id ?? null; + } + + if (compteId) { + stmt.run(compteId, row.remb_id); + updated++; + } + } + + res.json({ updated, total: rows.length }); + } catch (e) { next(e); } +}); + +export default router; diff --git a/backend/src/routes/secteurs-inv.js b/backend/src/routes/secteurs-inv.js new file mode 100644 index 0000000..fdf3374 --- /dev/null +++ b/backend/src/routes/secteurs-inv.js @@ -0,0 +1,100 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import db from '../db/index.js'; +import { HttpError } from '../middleware/errorHandler.js'; + +const router = Router(); +// Monté sous requireAuth dans server.js + +/* ───────────────────────────────────────────────────────────── + GET /api/secteurs-inv + Retourne les secteurs globaux (user_id IS NULL) + privés + de l'utilisateur connecté. +───────────────────────────────────────────────────────────────*/ +router.get('/', (req, res) => { + const userId = req.user.id; + const rows = db.prepare(` + SELECT + s.id, + s.nom, + CASE WHEN s.user_id IS NULL THEN 1 ELSE 0 END AS is_global, + (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 = ? + ) + ) AS nb_plateformes, + (SELECT COUNT(*) FROM investissement_secteurs_inv is2 WHERE is2.secteur_id = s.id + AND is2.investissement_id IN ( + SELECT inv.id FROM investissements inv + JOIN investisseurs i ON i.id = inv.investisseur_id + WHERE i.user_id = ? + ) + ) AS nb_investissements + FROM secteurs_inv s + WHERE s.user_id IS NULL OR s.user_id = ? + ORDER BY is_global DESC, s.nom + `).all(userId, userId, userId); + res.json(rows); +}); + +/* ───────────────────────────────────────────────────────────── + POST /api/secteurs-inv — créer un secteur privé +───────────────────────────────────────────────────────────────*/ +router.post('/', (req, res, next) => { + try { + const { nom } = z.object({ nom: z.string().min(1).max(200) }).parse(req.body); + const userId = req.user.id; + const dup = db.prepare( + 'SELECT id FROM secteurs_inv WHERE nom = ? AND (user_id IS NULL OR user_id = ?)' + ).get(nom.trim(), userId); + if (dup) throw new HttpError(409, `Le secteur "${nom.trim()}" existe déjà.`); + const r = db.prepare( + 'INSERT INTO secteurs_inv (nom, user_id) VALUES (?, ?)' + ).run(nom.trim(), userId); + res.status(201).json({ id: r.lastInsertRowid, nom: nom.trim(), is_global: 0, nb_plateformes: 0, nb_investissements: 0 }); + } catch (e) { next(e); } +}); + +/* ───────────────────────────────────────────────────────────── + PUT /api/secteurs-inv/:id — renommer (uniquement les privés) +───────────────────────────────────────────────────────────────*/ +router.put('/:id', (req, res, next) => { + try { + const { nom } = z.object({ nom: z.string().min(1).max(200) }).parse(req.body); + const userId = req.user.id; + const row = db.prepare('SELECT id, nom, user_id FROM secteurs_inv WHERE id = ?').get(req.params.id); + if (!row) throw new HttpError(404, 'Secteur introuvable'); + if (row.user_id === null) throw new HttpError(403, 'Les secteurs globaux ne peuvent pas être modifiés.'); + if (row.user_id !== userId) throw new HttpError(403, 'Ce secteur ne vous appartient pas.'); + const dup = db.prepare( + 'SELECT id FROM secteurs_inv WHERE nom = ? AND (user_id IS NULL OR user_id = ?) AND id != ?' + ).get(nom.trim(), userId, row.id); + if (dup) throw new HttpError(409, `Le secteur "${nom.trim()}" existe déjà.`); + db.prepare('UPDATE secteurs_inv SET nom = ? WHERE id = ?').run(nom.trim(), row.id); + res.json({ id: row.id, nom: nom.trim(), is_global: 0 }); + } catch (e) { next(e); } +}); + +/* ───────────────────────────────────────────────────────────── + DELETE /api/secteurs-inv/:id — supprimer (uniquement les privés) +───────────────────────────────────────────────────────────────*/ +router.delete('/:id', (req, res, next) => { + try { + const userId = req.user.id; + const row = db.prepare('SELECT id, user_id FROM secteurs_inv WHERE id = ?').get(req.params.id); + if (!row) throw new HttpError(404, 'Secteur introuvable'); + if (row.user_id === null) throw new HttpError(403, 'Les secteurs globaux ne peuvent pas être supprimés.'); + if (row.user_id !== userId) throw new HttpError(403, 'Ce secteur ne vous appartient pas.'); + + db.transaction(() => { + db.prepare('DELETE FROM plateforme_secteurs_inv WHERE secteur_id = ?').run(row.id); + db.prepare('DELETE FROM investissement_secteurs_inv WHERE secteur_id = ?').run(row.id); + db.prepare('DELETE FROM secteurs_inv WHERE id = ?').run(row.id); + })(); + res.status(204).end(); + } catch (e) { next(e); } +}); + +export default router; diff --git a/backend/src/routes/simul.js b/backend/src/routes/simul.js new file mode 100644 index 0000000..a6f416e --- /dev/null +++ b/backend/src/routes/simul.js @@ -0,0 +1,171 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import db from '../db/index.js'; +import { HttpError } from '../middleware/errorHandler.js'; +import { requireInvestisseur } from '../middleware/investisseurScope.js'; +import { buildSchedule, adjustSimulForActuals } from '../utils/schedule.js'; + +const router = Router(); + +const Schema = z.object({ + investissement_id: z.number().int().positive(), + numero_echeance: z.number().int().positive(), + date_prevue: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + capital_prevu: z.number().nonnegative().default(0), + interets_prevus: z.number().nonnegative().default(0), + total_prevu: z.number().nonnegative().default(0), + notes: z.string().optional(), +}); + +/* GET /api/simul/all — toutes les projections pour l'investisseur actif (ou scope=all) */ +router.get('/all', (req, res) => { + const userId = req.user.id; + const scopeAll = req.query.scope === 'all'; + const rawInvId = req.header('X-Investisseur-Id'); + const invId = Number(rawInvId); + + let rows; + if (scopeAll) { + rows = db.prepare(` + SELECT s.*, i.plateforme_id, i.investisseur_id + FROM simul_remboursements s + JOIN investissements i ON i.id = s.investissement_id + JOIN investisseurs inv ON inv.id = i.investisseur_id + WHERE inv.user_id = ? + ORDER BY s.date_prevue + `).all(userId); + } else { + if (!invId) return res.status(400).json({ error: 'Missing X-Investisseur-Id' }); + const ok = db.prepare('SELECT id FROM investisseurs WHERE id=? AND user_id=?').get(invId, userId); + if (!ok) return res.status(403).json({ error: 'Investisseur not owned by user' }); + rows = db.prepare(` + SELECT s.*, i.plateforme_id, i.investisseur_id + FROM simul_remboursements s + JOIN investissements i ON i.id = s.investissement_id + WHERE i.investisseur_id = ? + ORDER BY s.date_prevue + `).all(invId); + } + res.json(rows); +}); + +router.use(requireInvestisseur); + +function assertOwnedInvestissement(invId, userId) { + const row = db.prepare(` + SELECT i.id, i.montant_investi, i.taux_interet, i.duree_mois, i.type_remb, + i.freq_interets, i.date_premiere_echeance, i.date_souscription + FROM investissements i + JOIN investisseurs inv ON inv.id = i.investisseur_id + WHERE i.id = ? AND inv.user_id = ? + `).get(invId, userId); + if (!row) throw new HttpError(403, 'Investissement not in scope'); + return row; +} + +router.get('/', (req, res) => { + const { investissement_id } = req.query; + if (!investissement_id) { + return res.status(400).json({ error: 'investissement_id query param required' }); + } + assertOwnedInvestissement(Number(investissement_id), req.user.id); + const rows = db.prepare( + 'SELECT * FROM simul_remboursements WHERE investissement_id=? ORDER BY numero_echeance' + ).all(investissement_id); + res.json(rows); +}); + +router.post('/', (req, res, next) => { + try { + const body = Schema.parse(req.body); + assertOwnedInvestissement(body.investissement_id, req.user.id); + const r = db.prepare(` + INSERT INTO simul_remboursements + (investissement_id, numero_echeance, date_prevue, capital_prevu, interets_prevus, total_prevu, notes) + VALUES (?,?,?,?,?,?,?) + `).run( + body.investissement_id, body.numero_echeance, body.date_prevue, + body.capital_prevu, body.interets_prevus, body.total_prevu, body.notes || null, + ); + res.status(201).json({ id: r.lastInsertRowid, ...body }); + } catch (e) { next(e); } +}); + +/** + * Auto-generate the simulated repayment schedule for an investissement + * based on its montant, taux, durée, type_remb and freq_interets. + * + * Body: { investissement_id, replace?: boolean } + */ +router.post('/generate', (req, res, next) => { + try { + 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) { + throw new HttpError(400, 'Investissement requires taux_interet and duree_mois'); + } + + const tx = db.transaction(() => { + if (replace) { + db.prepare('DELETE FROM simul_remboursements WHERE investissement_id=?').run(investissement_id); + } + const start = inv.date_premiere_echeance || inv.date_souscription; + const echeances = buildSchedule({ + montant: inv.montant_investi, + taux: inv.taux_interet, + duree: inv.duree_mois, + type: inv.type_remb || 'in_fine', + freq: inv.freq_interets || 'mensuel', + startDate: start, + }); + 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 echeances) { + stmt.run(investissement_id, e.n, e.date, e.capital, e.interets, e.total); + } + return echeances.length; + }); + + const inserted = tx(); + res.status(201).json({ inserted }); + } catch (e) { next(e); } +}); + +/** + * Recalcule l'échéancier en tenant compte des remboursements réels enregistrés + * (remboursements anticipés + première période partielle). + * Équivalent à ce qui est déclenché automatiquement après chaque saisie de remboursement. + * + * Body: { investissement_id } + */ +router.post('/recalculate', (req, res, next) => { + try { + const { investissement_id } = req.body; + if (!investissement_id) throw new HttpError(400, 'investissement_id required'); + assertOwnedInvestissement(Number(investissement_id), req.user.id); + adjustSimulForActuals(db, Number(investissement_id)); + const rows = db.prepare( + 'SELECT * FROM simul_remboursements WHERE investissement_id=? ORDER BY numero_echeance' + ).all(investissement_id); + res.json({ recalculated: rows.length, rows }); + } catch (e) { next(e); } +}); + +router.delete('/:id', (req, res, next) => { + try { + const owns = db.prepare(` + SELECT s.id FROM simul_remboursements s + JOIN investissements i ON i.id = s.investissement_id + WHERE s.id=? AND i.investisseur_id=? + `).get(req.params.id, req.investisseur.id); + if (!owns) throw new HttpError(404, 'Not found'); + db.prepare('DELETE FROM simul_remboursements WHERE id=?').run(req.params.id); + res.status(204).end(); + } catch (e) { next(e); } +}); + +export default router; diff --git a/backend/src/routes/tauxCreditImpot.js b/backend/src/routes/tauxCreditImpot.js new file mode 100644 index 0000000..3364432 --- /dev/null +++ b/backend/src/routes/tauxCreditImpot.js @@ -0,0 +1,125 @@ +import { Router } from 'express'; +import { requireAdmin } from '../middleware/auth.js'; +import db from '../db/index.js'; + +const router = Router(); + +/* ── GET /api/taux-credit-impot — liste complète (auth requis, pas admin) ── */ +router.get('/', (req, res) => { + const rows = db.prepare( + 'SELECT * FROM taux_credit_impot ORDER BY nom_pays ASC' + ).all(); + res.json(rows); +}); + +/* ── GET /api/taux-credit-impot/:code — par code pays (pour jointure plateforme) ── */ +router.get('/pays/:code', (req, res) => { + const row = db.prepare( + 'SELECT * FROM taux_credit_impot WHERE UPPER(code_pays) = UPPER(?)' + ).get(req.params.code); + if (!row) return res.status(404).json({ error: 'Pays introuvable' }); + res.json(row); +}); + +/* ── POST /api/taux-credit-impot — créer (admin) ── */ +router.post('/', requireAdmin, (req, res) => { + const { + nom_pays, code_pays, + div_taux, div_taux_alt, div_taux_alt_label, div_exclusif_residence, + int_taux, int_taux_alt, int_taux_alt_label, int_exclusif_residence, + notice, statut_convention, date_suspension, ref_boi, + } = req.body; + + if (!nom_pays?.trim()) { + return res.status(400).json({ error: 'Le nom du pays est requis.' }); + } + + try { + const result = db.prepare(` + INSERT INTO taux_credit_impot + (nom_pays, code_pays, + div_taux, div_taux_alt, div_taux_alt_label, div_exclusif_residence, + int_taux, int_taux_alt, int_taux_alt_label, int_exclusif_residence, + statut_convention, date_suspension, ref_boi, notice) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) + `).run( + nom_pays.trim(), + code_pays?.trim()?.toUpperCase() || null, + div_taux != null ? Number(div_taux) : null, + div_taux_alt != null ? Number(div_taux_alt) : null, + div_taux_alt_label?.trim() || null, + div_exclusif_residence ? 1 : 0, + int_taux != null ? Number(int_taux) : null, + int_taux_alt != null ? Number(int_taux_alt) : null, + int_taux_alt_label?.trim() || null, + int_exclusif_residence ? 1 : 0, + statut_convention || 'active', + date_suspension || null, + ref_boi?.trim() || null, + notice?.trim() || null, + ); + const row = db.prepare('SELECT * FROM taux_credit_impot WHERE id = ?').get(result.lastInsertRowid); + res.status(201).json(row); + } catch (e) { + if (e.message?.includes('UNIQUE')) { + return res.status(409).json({ error: 'Ce pays existe déjà.' }); + } + throw e; + } +}); + +/* ── PUT /api/taux-credit-impot/:id — modifier (admin) ── */ +router.put('/:id', requireAdmin, (req, res) => { + const { + nom_pays, code_pays, + div_taux, div_taux_alt, div_taux_alt_label, div_exclusif_residence, + int_taux, int_taux_alt, int_taux_alt_label, int_exclusif_residence, + notice, statut_convention, date_suspension, ref_boi, + } = req.body; + + if (!nom_pays?.trim()) { + return res.status(400).json({ error: 'Le nom du pays est requis.' }); + } + + const existing = db.prepare('SELECT id FROM taux_credit_impot WHERE id = ?').get(Number(req.params.id)); + if (!existing) return res.status(404).json({ error: 'Introuvable' }); + + db.prepare(` + UPDATE taux_credit_impot SET + nom_pays = ?, code_pays = ?, + div_taux = ?, div_taux_alt = ?, div_taux_alt_label = ?, div_exclusif_residence = ?, + int_taux = ?, int_taux_alt = ?, int_taux_alt_label = ?, int_exclusif_residence = ?, + notice = ?, statut_convention = ?, date_suspension = ?, ref_boi = ?, + updated_at = datetime('now') + WHERE id = ? + `).run( + nom_pays.trim(), + code_pays?.trim()?.toUpperCase() || null, + div_taux != null && div_taux !== '' ? Number(div_taux) : null, + div_taux_alt != null && div_taux_alt !== '' ? Number(div_taux_alt) : null, + div_taux_alt_label?.trim() || null, + div_exclusif_residence ? 1 : 0, + int_taux != null && int_taux !== '' ? Number(int_taux) : null, + int_taux_alt != null && int_taux_alt !== '' ? Number(int_taux_alt) : null, + int_taux_alt_label?.trim() || null, + int_exclusif_residence ? 1 : 0, + notice?.trim() || null, + statut_convention || 'active', + date_suspension || null, + ref_boi?.trim() || null, + Number(req.params.id), + ); + + const row = db.prepare('SELECT * FROM taux_credit_impot WHERE id = ?').get(Number(req.params.id)); + res.json(row); +}); + +/* ── DELETE /api/taux-credit-impot/:id — supprimer (admin) ── */ +router.delete('/:id', requireAdmin, (req, res) => { + const existing = db.prepare('SELECT id FROM taux_credit_impot WHERE id = ?').get(Number(req.params.id)); + if (!existing) return res.status(404).json({ error: 'Introuvable' }); + db.prepare('DELETE FROM taux_credit_impot WHERE id = ?').run(Number(req.params.id)); + res.status(204).end(); +}); + +export default router; diff --git a/backend/src/routes/taxreport.js b/backend/src/routes/taxreport.js new file mode 100644 index 0000000..424ab1f --- /dev/null +++ b/backend/src/routes/taxreport.js @@ -0,0 +1,421 @@ +import { Router } from 'express'; +import db from '../db/index.js'; +import { requireInvestisseur } from '../middleware/investisseurScope.js'; + +const router = Router(); +router.use(requireInvestisseur); + +const round2 = v => Math.round((v ?? 0) * 100) / 100; + +router.get('/', (req, res) => { + const annee = req.query.annee || String(new Date().getFullYear() - 1); + const scopeAll = req.query.scope === 'all'; + const invCond = scopeAll + ? 'i.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)' + : 'i.investisseur_id = ?'; + const invArg = scopeAll ? req.user.id : req.investisseur.id; + + const recap = db.prepare(` + SELECT + COALESCE(SUM(r.interets_bruts),0) AS interets_bruts, + COALESCE(SUM(r.prelev_sociaux),0) AS prelev_sociaux, + COALESCE(SUM(r.prelev_forfaitaire),0) AS prelev_forfaitaire, + COALESCE(SUM(r.net_recu),0) AS net_recu, + COUNT(*) AS nb_remboursements + FROM remboursements r + JOIN investissements i ON i.id = r.investissement_id + WHERE ${invCond} + AND substr(r.date_remb,1,4) = ? + AND r.statut IN ('paye','partiel') + `).get(invArg, annee); + + const pertes = db.prepare(` + SELECT i.id, i.nom_projet, p.nom AS plateforme_nom, + i.montant_investi, + COALESCE((SELECT SUM(r.capital) FROM remboursements r WHERE r.investissement_id = i.id),0) AS capital_rembourse, + (i.montant_investi - + COALESCE((SELECT SUM(r.capital) FROM remboursements r WHERE r.investissement_id = i.id),0) + ) AS perte_capital, + i.statut, i.updated_at + FROM investissements i + JOIN plateformes p ON p.id = i.plateforme_id + WHERE ${invCond} + AND i.statut IN ('en_retard','cloture') + AND substr(i.updated_at,1,4) = ? + `).all(invArg, annee); + + const pertesTotales = pertes.reduce((s, p) => s + Math.max(0, p.perte_capital), 0); + + const detail = db.prepare(` + SELECT i.id AS investissement_id, i.nom_projet, p.nom AS plateforme_nom, + inv.nom AS investisseur_nom, + SUM(r.interets_bruts) AS interets_bruts, + SUM(r.prelev_sociaux) AS prelev_sociaux, + SUM(r.prelev_forfaitaire) AS prelev_forfaitaire, + SUM(r.net_recu) AS net_recu, + COUNT(r.id) AS nb_echeances + FROM remboursements r + JOIN investissements i ON i.id = r.investissement_id + JOIN investisseurs inv ON inv.id = i.investisseur_id + JOIN plateformes p ON p.id = i.plateforme_id + WHERE ${invCond} + AND substr(r.date_remb,1,4) = ? + AND r.statut IN ('paye','partiel') + GROUP BY i.id + ORDER BY interets_bruts DESC + `).all(invArg, annee); + + const corrCond = scopeAll + ? 'c.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)' + : 'c.investisseur_id = ?'; + + const corrections = db.prepare(` + SELECT c.id, c.date, c.montant, c.notes, + p.nom AS plateforme_nom, + inv.nom AS investisseur_nom + FROM corrections_solde c + JOIN plateformes p ON p.id = c.plateforme_id + JOIN investisseurs inv ON inv.id = c.investisseur_id + WHERE ${corrCond} + AND substr(c.date,1,4) = ? + ORDER BY c.date DESC + `).all(invArg, annee); + + const totalCorrections = corrections.reduce((s, c) => s + c.montant, 0); + + const interetsNets = recap.interets_bruts - recap.prelev_sociaux - recap.prelev_forfaitaire; + + const recapTotal = { + interets_bruts: round2(recap.interets_bruts), + prelev_sociaux: round2(recap.prelev_sociaux), + prelev_forfaitaire: round2(recap.prelev_forfaitaire), + interets_nets: round2(interetsNets), + interets_nets_corriges: round2(interetsNets + totalCorrections), + nb_remboursements: recap.nb_remboursements, + total_corrections: round2(totalCorrections), + }; + + const cases = { + case_2TR: round2(recap.interets_bruts), + case_2CK: round2(recap.prelev_forfaitaire), + case_2BH: round2(recap.interets_bruts), + case_2BH_perte_capital: round2(pertesTotales), + note: "Les cases sont indicatives. Verifiez avec votre situation fiscale reelle.", + }; + + res.json({ annee, recap: recapTotal, pertes, pertesTotales: round2(pertesTotales), detail, corrections, cases }); +}); + +router.get('/export', (req, res) => { + const annee = req.query.annee || String(new Date().getFullYear() - 1); + const scopeAll = req.query.scope === 'all'; + const invCond = scopeAll + ? 'i.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)' + : 'i.investisseur_id = ?'; + const invArg = scopeAll ? req.user.id : req.investisseur.id; + + const rows = db.prepare(` + SELECT i.nom_projet, p.nom AS plateforme, + r.date_remb, r.capital, r.interets_bruts, + r.prelev_sociaux, r.prelev_forfaitaire, 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 ${invCond} AND substr(r.date_remb,1,4) = ? + ORDER BY r.date_remb + `).all(invArg, annee); + + const corrCond2 = scopeAll + ? 'c.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)' + : 'c.investisseur_id = ?'; + + const corrRows = db.prepare(` + SELECT c.notes AS nom_projet, p.nom AS plateforme, c.date AS date_remb, + 0 AS capital, 0 AS interets_bruts, + 0 AS prelev_sociaux, 0 AS prelev_forfaitaire, + c.montant AS net_recu, 'correction_solde' AS statut + FROM corrections_solde c + JOIN plateformes p ON p.id = c.plateforme_id + WHERE ${corrCond2} AND substr(c.date,1,4) = ? + ORDER BY c.date + `).all(invArg, annee); + + const header = ['Projet','Plateforme','Date','Capital','Interets bruts','Prelev sociaux','Impot revenu','Net recu','Statut']; + const allRows = [...rows, ...corrRows]; + const csv = [ + header.join(';'), + ...allRows.map(r => [ + esc(r.nom_projet), esc(r.plateforme), r.date_remb, + r.capital, r.interets_bruts, r.prelev_sociaux, r.prelev_forfaitaire, + r.net_recu, r.statut, + ].join(';')), + ].join('\n'); + + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="2778-SD-${annee}.csv"`); + res.send('\uFEFF' + csv); +}); + +/* ── CERFA 2561 — synthèse par plateforme × investisseur ── */ +router.get('/cerfa2561', (req, res) => { + const annee = req.query.annee || String(new Date().getFullYear() - 1); + const scopeAll = req.query.scope === 'all'; + const invCond = scopeAll + ? 'i.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)' + : 'i.investisseur_id = ?'; + const invArg = scopeAll ? req.user.id : req.investisseur.id; + + const rows = db.prepare(` + SELECT + p.id AS plateforme_id, + p.nom AS plateforme_nom, + p.domiciliation, + p.fiscalite, + p.type_produit_fiscal, + inv.id AS investisseur_id, + inv.nom AS investisseur_nom, + inv.prenom AS investisseur_prenom, + COALESCE(SUM(r.interets_bruts), 0) AS interets_bruts, + COALESCE(SUM(r.prelev_sociaux), 0) AS prelev_sociaux, + COALESCE(SUM(r.prelev_forfaitaire), 0) AS prelev_forfaitaire + FROM remboursements r + JOIN investissements i ON i.id = r.investissement_id + JOIN plateformes p ON p.id = i.plateforme_id + JOIN investisseurs inv ON inv.id = i.investisseur_id + WHERE ${invCond} + AND substr(r.date_remb,1,4) = ? + AND r.statut IN ('paye','partiel') + AND r.type = 'normal' + GROUP BY p.id, inv.id + ORDER BY p.nom, inv.nom + `).all(invArg, annee); + + // Pertes en capital par plateforme × investisseur + const pertesRows = db.prepare(` + SELECT + p.id AS plateforme_id, + i.investisseur_id, + COALESCE(SUM( + i.montant_investi - + COALESCE((SELECT SUM(r2.capital) FROM remboursements r2 WHERE r2.investissement_id = i.id),0) + ), 0) AS perte_capital + FROM investissements i + JOIN plateformes p ON p.id = i.plateforme_id + WHERE ${invCond} + AND i.statut IN ('en_retard','cloture') + AND substr(i.updated_at,1,4) = ? + GROUP BY p.id, i.investisseur_id + `).all(invArg, annee); + + const pertesMap = {}; + for (const p of pertesRows) { + pertesMap[`${p.plateforme_id}_${p.investisseur_id}`] = Math.max(0, p.perte_capital); + } + + const lignes = rows.map(r => { + const key = `${r.plateforme_id}_${r.investisseur_id}`; + const perteCapital = pertesMap[key] ?? 0; + const isFlatTax = r.domiciliation === 'FR' && r.fiscalite === 'flat_tax'; + const use2TR = r.type_produit_fiscal === '2TR'; + const interetsNets = round2(r.interets_bruts - r.prelev_sociaux - r.prelev_forfaitaire); + return { + annee, + plateforme_id: r.plateforme_id, + plateforme_nom: r.plateforme_nom, + domiciliation: r.domiciliation, + fiscalite: r.fiscalite, + type_produit_fiscal: r.type_produit_fiscal, + investisseur_id: r.investisseur_id, + investisseur_nom: r.investisseur_nom, + investisseur_prenom: r.investisseur_prenom, + interets_bruts: round2(r.interets_bruts), + prelev_sociaux: round2(r.prelev_sociaux), + prelev_forfaitaire: round2(r.prelev_forfaitaire), + interets_nets: interetsNets, + // Cases fiscales — d'après IFU réels : + // 2TT ou 2TR = intérêts bruts (toutes plateformes) + // 2BH = intérêts bruts si PS déjà prélevés (prelev_sociaux > 0) + // 2CK = PFNL déjà versé (flat-tax FR uniquement) + // 2TY = pertes en capital + case_2TT: !use2TR ? Math.round(r.interets_bruts) : 0, + case_2TR: use2TR ? Math.round(r.interets_bruts) : 0, + case_2BH: r.prelev_sociaux > 0 ? Math.round(r.interets_bruts) : 0, + case_2CK: isFlatTax ? Math.round(r.prelev_forfaitaire) : 0, + case_2TY: perteCapital > 0 ? Math.round(perteCapital) : 0, + }; + }); + + + // Breakdown mensuel par plateforme × investisseur + const moisRows = db.prepare(` + SELECT + p.id AS plateforme_id, + i.investisseur_id, + substr(r.date_remb,6,2) AS mois, + COALESCE(SUM(r.interets_bruts), 0) AS interets_bruts, + COALESCE(SUM(r.prelev_sociaux), 0) AS prelev_sociaux, + COALESCE(SUM(r.prelev_forfaitaire), 0) AS prelev_forfaitaire + FROM remboursements r + JOIN investissements i ON i.id = r.investissement_id + JOIN plateformes p ON p.id = i.plateforme_id + WHERE ${invCond} + AND substr(r.date_remb,1,4) = ? + AND r.statut IN ('paye','partiel') + AND r.type = 'normal' + GROUP BY p.id, i.investisseur_id, mois + `).all(invArg, annee); + + const moisMap = {}; + for (const m of moisRows) { + const key = `${m.plateforme_id}_${m.investisseur_id}`; + if (!moisMap[key]) moisMap[key] = {}; + moisMap[key][m.mois] = { + interets_bruts: round2(m.interets_bruts), + prelev_sociaux: round2(m.prelev_sociaux), + prelev_forfaitaire: round2(m.prelev_forfaitaire), + }; + } + + const lignesWithMois = lignes.map(l => ({ + ...l, + mois: moisMap[`${l.plateforme_id}_${l.investisseur_id}`] ?? {}, + })); + + res.json({ annee, lignes: lignesWithMois }); +}); + +/* ── CERFA 2561 — détail des remboursements par plateforme × investisseur ── */ +router.get('/cerfa2561/remboursements', (req, res) => { + const { annee, plateforme_id, investisseur_id } = req.query; + if (!annee || !plateforme_id) return res.status(400).json({ error: 'annee et plateforme_id requis' }); + + const scopeAll = req.query.scope === 'all'; + const invCond = scopeAll + ? 'i.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)' + : 'i.investisseur_id = ?'; + const invArg = scopeAll ? req.user.id : req.investisseur.id; + + const platCond = investisseur_id + ? 'AND p.id = ? AND i.investisseur_id = ?' + : 'AND p.id = ?'; + const platArgs = investisseur_id + ? [Number(plateforme_id), Number(investisseur_id)] + : [Number(plateforme_id)]; + + const rows = db.prepare(` + SELECT + r.id, r.date_remb, r.capital, r.interets_bruts, + r.prelev_sociaux, r.prelev_forfaitaire, r.interets_nets, r.net_recu, + r.statut, r.notes, + i.nom_projet + FROM remboursements r + JOIN investissements i ON i.id = r.investissement_id + JOIN plateformes p ON p.id = i.plateforme_id + WHERE ${invCond} ${platCond} + AND substr(r.date_remb,1,4) = ? + AND r.statut IN ('paye','partiel') + AND r.type = 'normal' + ORDER BY r.date_remb + `).all(invArg, ...platArgs, annee); + + res.json(rows); +}); + + +/* ── Années disponibles ── */ +router.get('/years', (req, res) => { + const scopeAll = req.query.scope === 'all'; + let invWhere, invParams; + + if (scopeAll) { + invWhere = 'i.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'; + invParams = [req.user.id]; + } else { + const invId = Number(req.header('X-Investisseur-Id')); + if (!invId) return res.status(400).json({ error: 'Missing investisseur id' }); + invWhere = 'i.investisseur_id = ?'; + invParams = [invId]; + } + + const rows = db.prepare(` + SELECT DISTINCT strftime('%Y', r.date_remb) AS annee + FROM remboursements r + JOIN investissements i ON i.id = r.investissement_id + WHERE ${invWhere} + AND r.type = 'normal' + ORDER BY annee DESC + `).all(...invParams); + + res.json(rows.map(r => r.annee)); +}); + + + +/* ── 2778-SD — matrice mensuelle par plateforme étrangère ── */ +router.get('/2778', (req, res) => { + const annee = req.query.annee || String(new Date().getFullYear() - 1); + const scopeAll = req.query.scope === 'all'; + const invCond = scopeAll + ? 'i.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)' + : 'i.investisseur_id = ?'; + const invArg = scopeAll ? req.user.id : req.investisseur.id; + + // Plateformes étrangères × investisseur avec au moins un remboursement sur l'année + const platRows = db.prepare(` + SELECT DISTINCT p.id, p.nom, inv.id AS investisseur_id, inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom + FROM remboursements r + JOIN investissements i ON i.id = r.investissement_id + JOIN plateformes p ON p.id = i.plateforme_id + JOIN investisseurs inv ON inv.id = i.investisseur_id + WHERE ${invCond} + AND substr(r.date_remb,1,4) = ? + AND r.statut IN ('paye','partiel') + AND r.type = 'normal' + AND p.domiciliation != 'FR' + ORDER BY p.nom, inv.nom + `).all(invArg, annee); + + // Montants mensuels par plateforme × investisseur + const moisRows = db.prepare(` + SELECT + p.id AS plateforme_id, + i.investisseur_id, + substr(r.date_remb,6,2) AS mois, + COALESCE(SUM( + CASE WHEN r.interets_bruts_avant_local IS NOT NULL AND r.interets_bruts_avant_local > 0 + THEN r.interets_bruts_avant_local + ELSE r.interets_bruts + END + ), 0) AS montant + FROM remboursements r + JOIN investissements i ON i.id = r.investissement_id + JOIN plateformes p ON p.id = i.plateforme_id + WHERE ${invCond} + AND substr(r.date_remb,1,4) = ? + AND r.statut IN ('paye','partiel') + AND r.type = 'normal' + AND p.domiciliation != 'FR' + GROUP BY p.id, i.investisseur_id, mois + `).all(invArg, annee); + + const moisMap = {}; + for (const m of moisRows) { + const key = `${m.plateforme_id}_${m.investisseur_id}`; + if (!moisMap[key]) moisMap[key] = {}; + moisMap[key][m.mois] = round2(m.montant); + } + + const plateformes = platRows.map(p => ({ + id: `${p.id}_${p.investisseur_id}`, + plateforme_id: p.id, + nom: p.nom, + investisseur_id: p.investisseur_id, + investisseur_nom: p.investisseur_nom, + investisseur_prenom: p.investisseur_prenom, + mois: moisMap[`${p.id}_${p.investisseur_id}`] ?? {}, + })); + + res.json({ annee, plateformes }); +}); + +export default router; diff --git a/backend/src/server.js b/backend/src/server.js new file mode 100644 index 0000000..6adf9fe --- /dev/null +++ b/backend/src/server.js @@ -0,0 +1,106 @@ +import 'dotenv/config'; +import express from 'express'; +import cors from 'cors'; +import helmet from 'helmet'; +import morgan from 'morgan'; +import rateLimit from 'express-rate-limit'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +import authRouter from './routes/auth.js'; +import investisseursRouter from './routes/investisseurs.js'; +import plateformesRouter from './routes/plateformes.js'; +import categoriesRouter from './routes/categories.js'; +import depotsRetraitsRouter from './routes/depotsRetraits.js'; +import investissementsRouter from './routes/investissements.js'; +import remboursementsRouter from './routes/remboursements.js'; +import simulRouter from './routes/simul.js'; +import dashboardRouter from './routes/dashboard.js'; +import taxreportRouter from './routes/taxreport.js'; +import platefomeTaxRouter from './routes/plateforme-tax.js'; +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 reinvestissementsRouter from './routes/reinvestissements.js'; +import correctionsRouter from './routes/corrections.js'; +import comptesRouter from './routes/comptes.js'; +import preferencesRouter from './routes/preferences.js'; +import iconsRouter from './routes/icons.js'; +import { errorHandler } from './middleware/errorHandler.js'; +import { requireAuth, requireAdmin } from './middleware/auth.js'; +import { startAutoStatutJob } from './jobs/autoStatut.js'; +import adminRouter from './routes/admin.js'; +import tauxCreditImpotRouter from './routes/tauxCreditImpot.js'; +import referentielRouter from './routes/referentiel.js'; +import referentielPublicRouter from './routes/referentielPublic.js'; +import refCategoriesRouter from './routes/ref-categories.js'; +import refSecteursRouter from './routes/ref-secteurs.js'; +import categoriesInvRouter from './routes/categories-inv.js'; +import secteursInvRouter from './routes/secteurs-inv.js'; +import associationsInvRouter from './routes/associations-inv.js'; + +const app = express(); +const PORT = process.env.PORT || 4000; + +app.use(helmet({ crossOriginResourcePolicy: { policy: 'cross-origin' } })); +app.use(cors()); +app.use(express.json({ limit: '5mb' })); +app.use(morgan('dev')); + +// ── Logos plateformes — servis sans authentification ───────────────────── +const logosDir = path.resolve(__dirname, '../../data/logos'); +app.use('/api/logos', express.static(logosDir, { maxAge: '1d' })); +const iconsDir2 = path.resolve(__dirname, '../../data/icons'); +app.use('/api/icons-files', express.static(iconsDir2, { maxAge: '1h' })); + +// Basic rate-limit on auth endpoints +const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 50, + standardHeaders: true, + legacyHeaders: false, +}); + +app.get('/api/health', (_, res) => res.json({ ok: true, ts: new Date().toISOString() })); + +app.use('/api/auth', authLimiter, authRouter); + +// All routes below require authentication +app.use('/api/investisseurs', requireAuth, investisseursRouter); +app.use('/api/plateformes', requireAuth, plateformesRouter); +app.use('/api/categories', requireAuth, categoriesRouter); +app.use('/api/depots-retraits', requireAuth, depotsRetraitsRouter); +app.use('/api/investissements', requireAuth, investissementsRouter); +app.use('/api/remboursements', requireAuth, remboursementsRouter); +app.use('/api/simul', requireAuth, simulRouter); +app.use('/api/dashboard', requireAuth, dashboardRouter); +app.use('/api/taxreport', requireAuth, taxreportRouter); +app.use('/api/plateforme-tax', requireAuth, platefomeTaxRouter); +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/reinvestissements', requireAuth, reinvestissementsRouter); +app.use('/api/corrections', requireAuth, correctionsRouter); +app.use('/api/comptes', requireAuth, comptesRouter); +app.use('/api/preferences', requireAuth, preferencesRouter); +app.use('/api/icons', requireAuth, iconsRouter); +app.use('/api/admin', requireAuth, requireAdmin, adminRouter); +app.use('/api/taux-credit-impot', requireAuth, tauxCreditImpotRouter); +app.use('/api/referentiel', requireAuth, requireAdmin, referentielRouter); +app.use('/api/referentiel-public', requireAuth, referentielPublicRouter); +app.use('/api/ref-categories', requireAuth, requireAdmin, refCategoriesRouter); +app.use('/api/ref-secteurs', requireAuth, requireAdmin, refSecteursRouter); +app.use('/api/categories-inv', requireAuth, categoriesInvRouter); +app.use('/api/secteurs-inv', requireAuth, secteursInvRouter); +app.use('/api', requireAuth, associationsInvRouter); + +app.use(errorHandler); + +app.listen(PORT, () => { + console.log(`Crowdlending API listening on http://localhost:${PORT}`); + startAutoStatutJob(); +}); \ No newline at end of file diff --git a/backend/src/utils/schedule.js b/backend/src/utils/schedule.js new file mode 100644 index 0000000..1164b33 --- /dev/null +++ b/backend/src/utils/schedule.js @@ -0,0 +1,572 @@ +/** + * Shared schedule builder — used by both simul.js and investissements.js + */ + +function addMonths(isoDate, months) { + const [y, m, d] = isoDate.split('-').map(Number); + const dt = new Date(Date.UTC(y, m - 1 + months, d)); + return dt.toISOString().slice(0, 10); +} + +/** + * Retourne le dernier jour du mois de isoDate. + * Ex : '2024-01-15' → '2024-01-31', '2024-02-01' → '2024-02-29' + */ +function lastDayOfMonth(isoDate) { + const [y, m] = isoDate.split('-').map(Number); + const dt = new Date(Date.UTC(y, m, 0)); // jour 0 du mois suivant = dernier jour du mois courant + return dt.toISOString().slice(0, 10); +} + +/** + * Ajoute n mois à isoDate et positionne le résultat sur le dernier jour du mois cible. + * Évite les débordements de JavaScript (ex : 31 jan + 1 mois = 29 fév, pas 3 mar). + * Ex : addMonthsEOM('2024-01-31', 1) → '2024-02-29' + */ +function addMonthsEOM(isoDate, months) { + const [y, m] = isoDate.split('-').map(Number); + const dt = new Date(Date.UTC(y, m - 1 + months + 1, 0)); // jour 0 = dernier du mois cible + return dt.toISOString().slice(0, 10); +} + +const round2 = n => Math.round(n * 100) / 100; + +/** + * Builds a payment schedule. + * + * type: + * - in_fine : intérêts périodiques selon freq, capital à l'échéance + * - amortissable: mensualité/trimestrialité constante (modèle bancaire) + * - differe : aucun versement intermédiaire, tout en une fois à l'échéance + * + * freq: + * - mensuel : une échéance par mois (offset i-1) + * - trimestriel : une échéance tous les 3 mois + * - in_fine : forcé pour differe + * + * startDate = date_premiere_echeance (1ère échéance tombe sur cette date, offset 0) + */ +export function buildSchedule({ montant, taux, duree, type, freq, startDate, finDeMois = false }) { + const step = freq === 'trimestriel' ? 3 : 1; + const rPer = (taux / 100 / 12) * step; + const nPer = freq === 'in_fine' ? 1 : Math.round(duree / step); + const out = []; + + // Calcule la date d'une échéance selon le mode fin-de-mois ou non. + const dateAt = (base, offsetMonths) => + finDeMois ? addMonthsEOM(base, offsetMonths) : addMonths(base, offsetMonths); + + if (type === 'differe') { + const interets = round2(montant * (taux / 100 / 12) * duree); + // Pour un prêt différé, date_premiere_echeance == date_cible (versement unique). + // startDate est déjà la bonne date ; en mode fin-de-mois on s'assure qu'elle + // correspond bien au dernier jour (idempotent si déjà positionné par le frontend). + out.push({ + n: 1, + date: finDeMois ? lastDayOfMonth(startDate) : startDate, + capital: round2(montant), + interets, + total: round2(montant + interets), + }); + + } else if (type === 'in_fine') { + const interetsPer = round2(montant * rPer); + for (let i = 1; i <= nPer; i++) { + const isLast = i === nPer; + out.push({ + n: i, + date: dateAt(startDate, (i - 1) * step), + capital: isLast ? round2(montant) : 0, + interets: interetsPer, + total: round2(interetsPer + (isLast ? montant : 0)), + }); + } + + } else { + // amortissable + const a = rPer === 0 + ? montant / nPer + : montant * rPer / (1 - Math.pow(1 + rPer, -nPer)); + let restant = montant; + for (let i = 1; i <= nPer; i++) { + const interets = round2(restant * rPer); + const capital = round2(a - interets); + restant = round2(restant - capital); + out.push({ + n: i, + date: dateAt(startDate, (i - 1) * step), + capital, + interets, + total: round2(a), + }); + } + } + + return out; +} + +/** Nombre de mois entiers entre deux dates ISO */ +function monthsDiff(isoA, isoB) { + const a = new Date(isoA); + const b = new Date(isoB); + return (b.getFullYear() - a.getFullYear()) * 12 + (b.getMonth() - a.getMonth()); +} + +/** + * Recalcule les simul_remboursements FUTURS en tenant compte des remboursements + * de capital déjà enregistrés (remboursement anticipé partiel) ET des réinvestissements. + * + * - Les entrées dont date_prevue <= dernière date de remboursement capital sont laissées intactes. + * - Les entrées suivantes sont recalculées sur la base du capital restant dû. + * - Si aucun capital n'a encore été remboursé → régénération complète. + * - Les réinvestissements planifiés après la dernière date de remboursement sont pris en compte + * pour gonfler progressivement le capital des périodes futures. + */ +export function adjustSimulForActuals(db, investissementId) { + 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); + + if (!inv || !inv.taux_interet || !inv.duree_mois) return; + + // Réinvestissements triés par date (peuvent être vides) + const reinvests = db.prepare( + 'SELECT montant, date_reinvestissement FROM reinvestissements WHERE investissement_id = ? ORDER BY date_reinvestissement' + ).all(investissementId); + + const { total_capital } = db.prepare( + 'SELECT COALESCE(SUM(capital), 0) AS total_capital FROM remboursements WHERE investissement_id = ?' + ).get(investissementId); + + // Aucun capital remboursé → régénération complète (avec réinvestissements si présents) + if (total_capital <= 0) { + if (reinvests.length) { + generateSimulWithReinvestissements(db, investissementId); + } else { + generateSimul(db, inv); + } + adjustFirstPartialPeriod(db, investissementId); + return; + } + + // Date du dernier remboursement comportant du capital + const { last_date } = db.prepare( + 'SELECT MAX(date_remb) AS last_date FROM remboursements WHERE investissement_id = ? AND capital > 0' + ).get(investissementId); + + // Capital effectivement investi jusqu'à la date du dernier remboursement + // (initial + réinvestissements survenus avant ou à cette date) + const capitalAtLastDate = round2( + inv.montant_investi + + reinvests.filter(r => r.date_reinvestissement <= last_date).reduce((s, r) => s + r.montant, 0) + ); + + const remainingCapital = round2(capitalAtLastDate - total_capital); + + // Entrées de simulation à recalculer (strictement après la date du dernier remb capital) + const futureEntries = db.prepare(` + SELECT * FROM simul_remboursements + WHERE investissement_id = ? AND date_prevue > ? + ORDER BY numero_echeance + `).all(investissementId, last_date); + + if (futureEntries.length === 0) return; + + // Capital restant soldé → on met tout à zéro, et on porte le capital sur l'échéance courante + if (remainingCapital <= 0) { + // Entrée simul qui couvre la période du dernier remboursement capital + const lastPaidEntry = db.prepare(` + SELECT id, interets_prevus, capital_prevu + FROM simul_remboursements + WHERE investissement_id = ? AND date_prevue <= ? + ORDER BY date_prevue DESC + LIMIT 1 + `).get(investissementId, last_date); + + db.transaction(() => { + // Supprimer les échéances futures devenues caduques + const stmtDel = db.prepare('DELETE FROM simul_remboursements WHERE id=?'); + for (const e of futureEntries) stmtDel.run(e.id); + + // Mettre à jour l'échéance courante pour qu'elle reflète le capital soldé + if (lastPaidEntry) { + db.prepare(` + UPDATE simul_remboursements + SET capital_prevu=?, total_prevu=? + WHERE id=? + `).run( + capitalAtLastDate, + round2(capitalAtLastDate + lastPaidEntry.interets_prevus), + lastPaidEntry.id, + ); + } + })(); + return; + } + + const step = inv.freq_interets === 'trimestriel' ? 3 : 1; + const rPer = (inv.taux_interet / 100 / 12) * step; + const nFuture = futureEntries.length; + const type = inv.type_remb || 'in_fine'; + + // Réinvestissements encore à venir (après la date du dernier remboursement) + const futureReinvests = reinvests.filter(r => r.date_reinvestissement > last_date); + + const updates = []; + + if (type === 'differe') { + // Versement unique : recalcul sur capital restant + réinvestissements futurs + const entry = futureEntries[0]; + const extraCapital = futureReinvests + .filter(r => r.date_reinvestissement <= entry.date_prevue) + .reduce((s, r) => s + r.montant, 0); + const totalCap = round2(remainingCapital + extraCapital); + const moisRestants = monthsDiff(last_date, entry.date_prevue); + const interets = round2(totalCap * (inv.taux_interet / 100 / 12) * moisRestants); + updates.push({ id: entry.id, capital: totalCap, interets, total: round2(totalCap + interets) }); + + } else if (type === 'in_fine') { + // Intérêts sur capital courant (augmente à chaque réinvestissement futur) + let capital = remainingCapital; + let reinvestIdx = 0; + futureEntries.forEach((entry, i) => { + while (reinvestIdx < futureReinvests.length && + futureReinvests[reinvestIdx].date_reinvestissement <= entry.date_prevue) { + capital += futureReinvests[reinvestIdx].montant; + reinvestIdx++; + } + const isLast = i === nFuture - 1; + const interets = round2(capital * rPer); + updates.push({ + id: entry.id, + capital: isLast ? capital : 0, + interets, + total: round2(interets + (isLast ? capital : 0)), + }); + }); + + } else { + // Amortissable : recalcul période par période avec capital courant + let restant = remainingCapital; + let reinvestIdx = 0; + futureEntries.forEach((entry, i) => { + while (reinvestIdx < futureReinvests.length && + futureReinvests[reinvestIdx].date_reinvestissement <= entry.date_prevue) { + restant += futureReinvests[reinvestIdx].montant; + reinvestIdx++; + } + const periodsLeft = nFuture - i; + const a = rPer === 0 + ? restant / periodsLeft + : restant * rPer / (1 - Math.pow(1 + rPer, -periodsLeft)); + const interets = round2(restant * rPer); + const capital = round2(a - interets); + restant = round2(restant - capital); + updates.push({ id: entry.id, capital, interets, total: round2(a) }); + }); + } + + db.transaction(() => { + const stmt = db.prepare(` + UPDATE simul_remboursements + SET capital_prevu = ?, interets_prevus = ?, total_prevu = ? + WHERE id = ? + `); + for (const u of updates) stmt.run(u.capital, u.interets, u.total, u.id); + })(); + + // Ajustement de la première période partielle (mois incomplet) + adjustFirstPartialPeriod(db, investissementId); +} + +/** + * Détecte et corrige la première période partielle (mois incomplet au démarrage du prêt). + * + * Pour les prêts in_fine et amortissables, la première échéance avec intérêts correspond + * parfois à un mois incomplet (souscription en milieu de mois). Si le premier remboursement + * réel avec intérêts est inférieur au montant simulé, on met à jour la ligne simul + * correspondante avec la valeur réelle et on reporte la différence sur la dernière échéance. + * + * L'ajustement est idempotent : si les valeurs sont déjà cohérentes, rien n'est modifié. + */ +function adjustFirstPartialPeriod(db, investissementId) { + const inv = db.prepare( + 'SELECT type_remb FROM investissements WHERE id = ?' + ).get(investissementId); + + if (!inv || inv.type_remb === 'differe') return; + + // Premier remboursement enregistré avec des intérêts (exclure cashback-only) + const firstRemb = db.prepare(` + SELECT date_remb, interets_bruts + FROM remboursements + WHERE investissement_id = ? AND interets_bruts > 0 + ORDER BY date_remb ASC + LIMIT 1 + `).get(investissementId); + + if (!firstRemb) return; + + const firstRembMonth = firstRemb.date_remb.slice(0, 7); // YYYY-MM + + // Entrée simul du même mois YYYY-MM + const simulEntry = db.prepare(` + SELECT id, interets_prevus, capital_prevu, total_prevu + FROM simul_remboursements + WHERE investissement_id = ? AND substr(date_prevue, 1, 7) = ? + `).get(investissementId, firstRembMonth); + + if (!simulEntry) return; + + const diff = round2(simulEntry.interets_prevus - firstRemb.interets_bruts); + if (diff <= 0.001) return; // Pas d'écart significatif, aucune correction nécessaire + + // Dernière échéance simul (celle qui absorbera la différence) + const lastEntry = db.prepare(` + SELECT id, interets_prevus, capital_prevu, total_prevu + FROM simul_remboursements + WHERE investissement_id = ? + ORDER BY numero_echeance DESC + LIMIT 1 + `).get(investissementId); + + if (!lastEntry || lastEntry.id === simulEntry.id) return; + + const newFirstInterets = round2(firstRemb.interets_bruts); + const newFirstTotal = round2(simulEntry.capital_prevu + newFirstInterets); + const newLastInterets = round2(lastEntry.interets_prevus + diff); + const newLastTotal = round2(lastEntry.capital_prevu + newLastInterets); + + db.transaction(() => { + db.prepare( + 'UPDATE simul_remboursements SET interets_prevus=?, total_prevu=? WHERE id=?' + ).run(newFirstInterets, newFirstTotal, simulEntry.id); + db.prepare( + 'UPDATE simul_remboursements SET interets_prevus=?, total_prevu=? WHERE id=?' + ).run(newLastInterets, newLastTotal, lastEntry.id); + })(); +} + +/** + * Génère (ou régénère) le tableau d'amortissement en tenant compte des réinvestissements. + * + * Pour chaque période, le capital pris en compte est : + * montant_investi + SUM(reinvestissements dont date <= date_période) + * + * - in_fine : intérêts recalculés sur le capital cumulé à chaque période, + * capital final = capital total cumulé + * - differe : versement unique recalculé sur le capital total à l'échéance + * - amortissable : intérêts de base + quote-part du réinvestissement sur les périodes restantes + */ +export function generateSimulWithReinvestissements(db, investissementId) { + 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); + + if (!inv) return; + + const reinvests = db.prepare( + 'SELECT montant, date_reinvestissement FROM reinvestissements WHERE investissement_id = ? ORDER BY date_reinvestissement' + ).all(investissementId); + + // Pas de réinvestissement → génération standard + if (!reinvests.length) { + generateSimul(db, inv); + return; + } + + if (!inv.taux_interet || !inv.duree_mois) return; + + const startDate = inv.date_debut_simul || inv.date_premiere_echeance || inv.date_souscription; + if (!startDate) return; + + const finDeMois = !!inv.echeance_fin_de_mois; + const type = inv.type_remb || 'in_fine'; + const freq = inv.freq_interets || 'mensuel'; + const step = freq === 'trimestriel' ? 3 : 1; + const rPer = (inv.taux_interet / 100 / 12) * step; + + // Durée effective (tient compte d'une éventuelle restructuration) + let effectiveDuree = inv.duree_mois; + if (inv.date_debut_simul && inv.date_premiere_echeance && inv.date_debut_simul > inv.date_premiere_echeance) { + const elapsed = monthsDiff(inv.date_premiere_echeance, inv.date_debut_simul); + effectiveDuree = Math.max(1, inv.duree_mois - elapsed); + } + + // Calendrier de base pour obtenir les dates de chaque échéance + const baseSchedule = buildSchedule({ + montant: inv.montant_investi, + taux: inv.taux_interet, + duree: effectiveDuree, + type, freq, startDate, finDeMois, + }); + + let capital = inv.montant_investi; + let reinvestIdx = 0; + const schedule = []; + + // Pour amortissable : on garde la structure du capital initial et ajoute l'impact + // du réinvestissement sur les périodes restantes (quote-part d'intérêts supplémentaires). + // Indexes des réinvestissements non encore traités dans le plan amorti. + let pendingReinvests = []; + + for (let i = 0; i < baseSchedule.length; i++) { + const entry = baseSchedule[i]; + + // Réinvestissements dont la date tombe avant (ou sur) cette échéance + while (reinvestIdx < reinvests.length && + reinvests[reinvestIdx].date_reinvestissement <= entry.date) { + const r = reinvests[reinvestIdx]; + capital += r.montant; + // Pour amortissable : mémoriser le montant réinvesti et le nb de périodes restantes + if (type === 'amortissable') { + pendingReinvests.push({ montant: r.montant, remainingPeriods: baseSchedule.length - i }); + } + reinvestIdx++; + } + + const isLast = i === baseSchedule.length - 1; + + let entryCapital, entryInterets, entryTotal; + + if (type === 'in_fine') { + entryInterets = round2(capital * rPer); + entryCapital = isLast ? capital : 0; + entryTotal = round2(entryInterets + entryCapital); + + } else if (type === 'differe') { + // Versement unique : intérêts recalculés sur le capital total depuis le début + const totalMonths = inv.duree_mois; + entryInterets = round2(capital * (inv.taux_interet / 100 / 12) * totalMonths); + entryCapital = capital; + entryTotal = round2(entryCapital + entryInterets); + + } else { + // Amortissable : intérêts de base + quote-part des réinvestissements + const baseInterets = entry.interets; + const extraInterets = pendingReinvests.reduce((sum, pr) => { + return sum + round2(pr.montant * rPer); + }, 0); + entryInterets = round2(baseInterets + extraInterets); + entryCapital = entry.capital; + entryTotal = round2(entryCapital + entryInterets); + } + + schedule.push({ n: entry.n, date: entry.date, capital: entryCapital, interets: entryInterets, total: entryTotal }); + } + + 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); + } + })(); +} + +/** + * Génère (ou régénère) le tableau d'amortissement d'un investissement dans la DB. + * Ne fait rien si taux_interet ou duree_mois est absent. + * + * Si date_debut_simul est renseigné (restructuration de prêt), la simulation + * démarre à cette date et la durée effective est réduite du nombre de mois déjà + * écoulés depuis date_premiere_echeance, afin de ne pas allonger artificiellement le prêt. + */ +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; + + // 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; + if (!startDate) return; + + // Durée effective : si restructuration, on soustrait les mois déjà écoulés + // pour que la simulation se termine bien à la date cible contractuelle d'origine. + let effectiveDuree = duree_mois; + if (date_debut_simul && date_premiere_echeance && date_debut_simul > date_premiere_echeance) { + const elapsed = monthsDiff(date_premiere_echeance, date_debut_simul); + effectiveDuree = Math.max(1, duree_mois - elapsed); + } + + const echeances = buildSchedule({ + montant: montant_investi, + taux: taux_interet, + duree: effectiveDuree, + type: type_remb || 'in_fine', + freq: freq_interets || 'mensuel', + startDate, + finDeMois: !!echeance_fin_de_mois, + }); + + const tx = db.transaction(() => { + if (date_debut_simul) { + // ── Mode restructuration ────────────────────────────────────────────── + // Conserver uniquement les échéances déjà payées avant la date de restructuration + // (correspondance par mois YYYY-MM avec les remboursements réels enregistrés). + // Les échéances de la période creuse (non payées entre fin de la phase initiale + // et date_debut_simul) sont supprimées avec tout ce qui suit. + 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(id, date_debut_simul); + + if (keptEntries.length > 0) { + // Supprime tout sauf les entrées payées conservées + db.prepare( + `DELETE FROM simul_remboursements WHERE investissement_id = ? AND id NOT IN (${keptEntries.map(() => '?').join(',')})` + ).run(id, ...keptEntries.map(e => e.id)); + } else { + // Rien à conserver → suppression totale + db.prepare('DELETE FROM simul_remboursements WHERE investissement_id=?').run(id); + } + + // Numérotation absolue : position dans le prêt total = mois écoulés depuis la 1ère échéance. + // Ex : date_premiere_echeance = août 2024, date_debut_simul = juillet 2025 + // → 11 mois écoulés → nouvelle échéance 1 = n° 12, dernière = n° 48 (sur 48 total). + // On ne se base PAS sur le nombre d'entrées conservées (qui peut différer si certains + // paiements in fine ne matchent pas exactement) mais sur le décalage calendaire réel. + const elapsedMonths = (date_premiere_echeance && date_debut_simul > date_premiere_echeance) + ? monthsDiff(date_premiere_echeance, date_debut_simul) + : keptEntries.length; // fallback : nombre de lignes conservées + + 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 echeances) { + stmt.run(id, 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(id); + 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 echeances) { + stmt.run(id, e.n, e.date, e.capital, e.interets, e.total); + } + } + }); + tx(); +} diff --git a/backend/src/utils/zip.js b/backend/src/utils/zip.js new file mode 100644 index 0000000..58f2b25 --- /dev/null +++ b/backend/src/utils/zip.js @@ -0,0 +1,157 @@ +/** + * zip.js — Minimal pure-Node.js ZIP builder / reader. + * No external dependencies — uses only Node's built-in zlib. + */ +import zlib from 'zlib'; + +// ── CRC-32 ──────────────────────────────────────────────────────────────── +const _crcTable = (() => { + const t = new Uint32Array(256); + for (let i = 0; i < 256; i++) { + let c = i; + for (let j = 0; j < 8; j++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); + t[i] = c; + } + return t; +})(); + +function crc32(buf) { + let crc = 0xFFFFFFFF; + for (let i = 0; i < buf.length; i++) crc = (crc >>> 8) ^ _crcTable[(crc ^ buf[i]) & 0xFF]; + return (crc ^ 0xFFFFFFFF) >>> 0; +} + +// ── DOS date/time ───────────────────────────────────────────────────────── +function dosDateTime(d = new Date()) { + const time = ((d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1)) & 0xFFFF; + const date = (((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate()) & 0xFFFF; + return { time, date }; +} + +/** + * createZip(entries) → Buffer + * entries: [{ name: string, data: Buffer|string }] + */ +export function createZip(entries) { + const chunks = []; + const centralHeaders = []; + let offset = 0; + const { time: modTime, date: modDate } = dosDateTime(); + + for (let { name, data } of entries) { + if (typeof data === 'string') data = Buffer.from(data, 'utf8'); + const nameBuf = Buffer.from(name, 'utf8'); + const crc = crc32(data); + const uncompSize = data.length; + + // Try deflate; use stored if compressed is larger + const deflated = zlib.deflateRawSync(data); + const useDeflate = deflated.length < uncompSize; + const compData = useDeflate ? deflated : data; + const method = useDeflate ? 8 : 0; + + // Local file header + const lh = Buffer.alloc(30 + nameBuf.length); + lh.writeUInt32LE(0x04034b50, 0); + lh.writeUInt16LE(20, 4); + lh.writeUInt16LE(0, 6); + lh.writeUInt16LE(method, 8); + lh.writeUInt16LE(modTime, 10); + lh.writeUInt16LE(modDate, 12); + lh.writeUInt32LE(crc, 14); + lh.writeUInt32LE(compData.length, 18); + lh.writeUInt32LE(uncompSize, 22); + lh.writeUInt16LE(nameBuf.length, 26); + lh.writeUInt16LE(0, 28); + nameBuf.copy(lh, 30); + + chunks.push(lh, compData); + centralHeaders.push({ name: nameBuf, crc, method, modTime, modDate, compSize: compData.length, uncompSize, offset }); + offset += lh.length + compData.length; + } + + // Central directory + const cdStart = offset; + for (const h of centralHeaders) { + const cd = Buffer.alloc(46 + h.name.length); + cd.writeUInt32LE(0x02014b50, 0); + cd.writeUInt16LE(20, 4); + cd.writeUInt16LE(20, 6); + cd.writeUInt16LE(0, 8); + cd.writeUInt16LE(h.method, 10); + cd.writeUInt16LE(h.modTime, 12); + cd.writeUInt16LE(h.modDate, 14); + cd.writeUInt32LE(h.crc, 16); + cd.writeUInt32LE(h.compSize, 20); + cd.writeUInt32LE(h.uncompSize, 24); + cd.writeUInt16LE(h.name.length, 28); + cd.writeUInt16LE(0, 30); + cd.writeUInt16LE(0, 32); + cd.writeUInt16LE(0, 34); + cd.writeUInt16LE(0, 36); + cd.writeUInt32LE(0, 38); + cd.writeUInt32LE(h.offset, 42); + h.name.copy(cd, 46); + chunks.push(cd); + offset += cd.length; + } + + const cdSize = offset - cdStart; + + // End of central directory + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(0x06054b50, 0); + eocd.writeUInt16LE(0, 4); + eocd.writeUInt16LE(0, 6); + eocd.writeUInt16LE(centralHeaders.length, 8); + eocd.writeUInt16LE(centralHeaders.length, 10); + eocd.writeUInt32LE(cdSize, 12); + eocd.writeUInt32LE(cdStart, 16); + eocd.writeUInt16LE(0, 20); + chunks.push(eocd); + + return Buffer.concat(chunks); +} + +/** + * readZip(buffer) → [{ name: string, data: Buffer }] + */ +export function readZip(buffer) { + // Scan backwards for EOCD signature + let eocdOffset = -1; + for (let i = buffer.length - 22; i >= Math.max(0, buffer.length - 65558); i--) { + if (buffer.readUInt32LE(i) === 0x06054b50) { eocdOffset = i; break; } + } + if (eocdOffset === -1) throw new Error('ZIP invalide : signature EOCD introuvable'); + + const entryCount = buffer.readUInt16LE(eocdOffset + 8); + const cdOffset = buffer.readUInt32LE(eocdOffset + 16); + + const entries = []; + let pos = cdOffset; + + for (let i = 0; i < entryCount; i++) { + if (buffer.readUInt32LE(pos) !== 0x02014b50) throw new Error('ZIP invalide : signature Central Directory incorrecte'); + const method = buffer.readUInt16LE(pos + 10); + const compSize = buffer.readUInt32LE(pos + 20); + const uncompSize = buffer.readUInt32LE(pos + 24); + const nameLen = buffer.readUInt16LE(pos + 28); + const extraLen = buffer.readUInt16LE(pos + 30); + const commentLen = buffer.readUInt16LE(pos + 32); + const localOffset = buffer.readUInt32LE(pos + 42); + const name = buffer.toString('utf8', pos + 46, pos + 46 + nameLen); + + // Read local file header to get actual extra field length + const localNameLen = buffer.readUInt16LE(localOffset + 26); + const localExtraLen = buffer.readUInt16LE(localOffset + 28); + const dataStart = localOffset + 30 + localNameLen + localExtraLen; + + const compData = buffer.subarray(dataStart, dataStart + compSize); + const data = method === 0 ? compData : zlib.inflateRawSync(compData); + + entries.push({ name, data: Buffer.from(data) }); + pos += 46 + nameLen + extraLen + commentLen; + } + + return entries; +} diff --git a/comparaison_champs_referentiel_plateformes.csv b/comparaison_champs_referentiel_plateformes.csv new file mode 100644 index 0000000..64ea419 --- /dev/null +++ b/comparaison_champs_referentiel_plateformes.csv @@ -0,0 +1,46 @@ +"Champ rfrentiel";"Champ plateformes";"Hrit";"Notes" +"id";"id";"Non";"Cls primaires indpendantes" +"nom";"nom";"Oui";"HERITABLE_FIELDS" +"url";"url";"Non";"Prsent dans les deux; pas hrit" +"description";"";"--";"Rfrentiel seulement" +"annee_creation";"";"--";"Rfrentiel seulement" +"type_investissement";"";"--";"Rfrentiel seulement" +"secteur";"";"--";"Rfrentiel seulement (remplac par categories_inv/secteurs_inv ct user)" +"investisseurs_types";"";"--";"Rfrentiel seulement" +"domiciliation";"domiciliation";"Oui";"HERITABLE_FIELDS" +"fiscalite";"fiscalite";"Oui";"HERITABLE_FIELDS" +"taux_fiscalite_locale";"taux_fiscalite_locale";"Oui";"HERITABLE_FIELDS" +"type_produit_fiscal";"type_produit_fiscal";"Oui";"HERITABLE_FIELDS" +"logo_filename";"logo_filename";"Oui";"HERITABLE_FIELDS" +"icone_filename";"icone_filename";"Oui";"HERITABLE_FIELDS" +"regulateur";"";"--";"Rfrentiel seulement" +"numero_licence";"";"--";"Rfrentiel seulement" +"is_regule";"";"--";"Rfrentiel seulement" +"pays_inscription";"";"--";"Rfrentiel seulement" +"pays_siege";"";"--";"Rfrentiel seulement" +"pays_operation";"";"--";"Rfrentiel seulement" +"investissement_minimum";"";"--";"Rfrentiel seulement" +"rendement_annonce";"";"--";"Rfrentiel seulement" +"nb_investisseurs";"";"--";"Rfrentiel seulement" +"volume_total_finance";"";"--";"Rfrentiel seulement" +"duree_moyenne_pret";"";"--";"Rfrentiel seulement" +"garantie_rachat";"";"--";"Rfrentiel seulement" +"statistiques_publiques";"";"--";"Rfrentiel seulement" +"bonus_inscription";"";"--";"Rfrentiel seulement" +"marche_secondaire";"";"--";"Rfrentiel seulement" +"investissement_auto";"";"--";"Rfrentiel seulement" +"url_trustpilot";"";"--";"Rfrentiel seulement" +"url_linkedin";"";"--";"Rfrentiel seulement" +"created_at";"created_at";"Non";"Prsent dans les deux; timestamps indpendants" +"updated_at";"";"--";"Rfrentiel seulement (plateformes n'a pas updated_at)" +"";"user_id";"--";"Plateformes seulement -- lien vers l'utilisateur" +"";"notes";"--";"Plateformes seulement -- notes libres" +"";"methode_remboursement";"--";"Plateformes seulement" +"";"investisseur_id";"--";"Plateformes seulement -- dtenteur" +"";"date_ouverture";"--";"Plateformes seulement" +"";"type_pret_defaut";"--";"Plateformes seulement -- valeur par dfaut formulaire" +"";"duree_defaut";"--";"Plateformes seulement -- valeur par dfaut formulaire" +"";"taux_defaut";"--";"Plateformes seulement -- valeur par dfaut formulaire" +"";"freq_interets_defaut";"--";"Plateformes seulement -- valeur par dfaut formulaire" +"";"referentiel_id";"--";"Plateformes seulement -- lien vers le rfrentiel" +"";"overridden_fields";"--";"Plateformes seulement -- champs surchargs (JSON)" \ No newline at end of file diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..194917e --- /dev/null +++ b/deploy.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# ============================================================================= +# deploy.sh — Mise à jour de crowdlending-app en production +# +# Usage : ./deploy.sh [--no-backup] +# --no-backup Saute la sauvegarde de la base (déconseillé) +# +# À exécuter sur le CT Proxmox dans /opt/crowdlending-app +# ============================================================================= + +set -euo pipefail + +APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BACKUP_DIR="$APP_DIR/backups" +DATE=$(date +%Y%m%d_%H%M%S) +DO_BACKUP=true + +# --- Arguments --- +for arg in "$@"; do + case $arg in + --no-backup) DO_BACKUP=false ;; + esac +done + +echo "" +echo "=========================================" +echo " Déploiement crowdlending-app — $DATE" +echo "=========================================" + +cd "$APP_DIR" + +# 1. Vérifications préalables +if [ ! -f ".env" ]; then + echo "❌ Fichier .env manquant. Copier .env.example et remplir les valeurs." + exit 1 +fi + +# 2. Sauvegarde de la base SQLite avant toute modification +if [ "$DO_BACKUP" = true ]; then + echo "" + echo "📦 Sauvegarde de la base de données..." + mkdir -p "$BACKUP_DIR" + + # Trouver le fichier .db dans le volume Docker + DB_CONTAINER="crowdlending-backend" + if docker ps --format '{{.Names}}' | grep -q "$DB_CONTAINER"; then + docker exec "$DB_CONTAINER" sqlite3 /app/data/crowdlending.db ".backup '/app/data/backup_tmp.db'" 2>/dev/null || true + docker cp "$DB_CONTAINER":/app/data/backup_tmp.db "$BACKUP_DIR/crowdlending_$DATE.db" 2>/dev/null \ + && echo " ✓ Sauvegarde : backups/crowdlending_$DATE.db" \ + || echo " ⚠ Impossible de copier la base (container arrêté ?), on continue." + else + echo " ⚠ Container arrêté, pas de sauvegarde." + fi + + # Garder seulement les 10 dernières sauvegardes + ls -t "$BACKUP_DIR"/crowdlending_*.db 2>/dev/null | tail -n +11 | xargs -r rm -- +fi + +# 3. Récupération du code +echo "" +echo "⬇ Récupération du code (git pull)..." +git pull --ff-only + +# 4. Reconstruction et redémarrage +echo "" +echo "🔨 Build et redémarrage des containers..." +docker compose build --no-cache +docker compose up -d + +# 5. Attente que le backend soit healthy +echo "" +echo "⏳ Attente démarrage du backend..." +MAX_WAIT=60 +WAITED=0 +until docker inspect --format='{{.State.Health.Status}}' crowdlending-backend 2>/dev/null | grep -q "healthy"; do + if [ $WAITED -ge $MAX_WAIT ]; then + echo "❌ Le backend ne répond pas après ${MAX_WAIT}s. Vérifier les logs :" + echo " docker compose logs backend --tail=50" + exit 1 + fi + sleep 3 + WAITED=$((WAITED + 3)) + echo " ... ($WAITED s)" +done + +echo "" +echo "✅ Déploiement terminé avec succès !" +echo " Frontend : http://$(hostname -I | awk '{print $1}'):8080" +echo " Logs : docker compose logs -f" diff --git a/design/Icones/png/Balance.png b/design/Icones/png/Balance.png new file mode 100644 index 0000000..6fc2590 Binary files /dev/null and b/design/Icones/png/Balance.png differ diff --git a/design/Icones/png/Capital.png b/design/Icones/png/Capital.png new file mode 100644 index 0000000..1c1ac1a Binary files /dev/null and b/design/Icones/png/Capital.png differ diff --git a/design/Icones/png/Cashback.png b/design/Icones/png/Cashback.png new file mode 100644 index 0000000..fca6334 Binary files /dev/null and b/design/Icones/png/Cashback.png differ diff --git a/design/Icones/png/Compte-courant.png b/design/Icones/png/Compte-courant.png new file mode 100644 index 0000000..93fb01b Binary files /dev/null and b/design/Icones/png/Compte-courant.png differ diff --git a/design/Icones/png/Dashboard.png b/design/Icones/png/Dashboard.png new file mode 100644 index 0000000..264bc01 Binary files /dev/null and b/design/Icones/png/Dashboard.png differ diff --git a/design/Icones/png/Dépôt.png b/design/Icones/png/Dépôt.png new file mode 100644 index 0000000..d89de30 Binary files /dev/null and b/design/Icones/png/Dépôt.png differ diff --git a/design/Icones/png/Dépôts-retraits.png b/design/Icones/png/Dépôts-retraits.png new file mode 100644 index 0000000..cbb741d Binary files /dev/null and b/design/Icones/png/Dépôts-retraits.png differ diff --git a/design/Icones/png/Intérêts.png b/design/Icones/png/Intérêts.png new file mode 100644 index 0000000..c33d944 Binary files /dev/null and b/design/Icones/png/Intérêts.png differ diff --git a/design/Icones/png/Investissement.png b/design/Icones/png/Investissement.png new file mode 100644 index 0000000..0927141 Binary files /dev/null and b/design/Icones/png/Investissement.png differ diff --git a/design/Icones/png/Plateforrme.png b/design/Icones/png/Plateforrme.png new file mode 100644 index 0000000..0483847 Binary files /dev/null and b/design/Icones/png/Plateforrme.png differ diff --git a/design/Icones/png/Porte-monaie.png b/design/Icones/png/Porte-monaie.png new file mode 100644 index 0000000..bb378a0 Binary files /dev/null and b/design/Icones/png/Porte-monaie.png differ diff --git a/design/Icones/png/Remboursement.png b/design/Icones/png/Remboursement.png new file mode 100644 index 0000000..a24702c Binary files /dev/null and b/design/Icones/png/Remboursement.png differ diff --git a/design/Icones/png/Retrait.png b/design/Icones/png/Retrait.png new file mode 100644 index 0000000..93df637 Binary files /dev/null and b/design/Icones/png/Retrait.png differ diff --git a/design/Icones/png/Tax.png b/design/Icones/png/Tax.png new file mode 100644 index 0000000..313145a Binary files /dev/null and b/design/Icones/png/Tax.png differ diff --git a/design/Icones/svg/Balance.svg b/design/Icones/svg/Balance.svg new file mode 100644 index 0000000..02edf3a Binary files /dev/null and b/design/Icones/svg/Balance.svg differ diff --git a/design/Icones/svg/Capital.svg b/design/Icones/svg/Capital.svg new file mode 100644 index 0000000..af6094a --- /dev/null +++ b/design/Icones/svg/Capital.svg @@ -0,0 +1,168 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/design/Icones/svg/Dashboard.svg b/design/Icones/svg/Dashboard.svg new file mode 100644 index 0000000..d014750 --- /dev/null +++ b/design/Icones/svg/Dashboard.svg @@ -0,0 +1,346 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/design/Icones/svg/Dépôt.svg b/design/Icones/svg/Dépôt.svg new file mode 100644 index 0000000..835c79a --- /dev/null +++ b/design/Icones/svg/Dépôt.svg @@ -0,0 +1,102 @@ + + + + + + + \ No newline at end of file diff --git a/design/Icones/svg/Dépôts-retraits.svg b/design/Icones/svg/Dépôts-retraits.svg new file mode 100644 index 0000000..1ec0b80 --- /dev/null +++ b/design/Icones/svg/Dépôts-retraits.svg @@ -0,0 +1,206 @@ + + + + + + + \ No newline at end of file diff --git a/design/Icones/svg/Intérêts.svg b/design/Icones/svg/Intérêts.svg new file mode 100644 index 0000000..be4fa2b --- /dev/null +++ b/design/Icones/svg/Intérêts.svg @@ -0,0 +1,321 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/design/Icones/svg/Investissement.svg b/design/Icones/svg/Investissement.svg new file mode 100644 index 0000000..60cc99a --- /dev/null +++ b/design/Icones/svg/Investissement.svg @@ -0,0 +1,176 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/design/Icones/svg/Plateforme.svg b/design/Icones/svg/Plateforme.svg new file mode 100644 index 0000000..5441dfc --- /dev/null +++ b/design/Icones/svg/Plateforme.svg @@ -0,0 +1,117 @@ + + + + + + + + \ No newline at end of file diff --git a/design/Icones/svg/Remboursement.svg b/design/Icones/svg/Remboursement.svg new file mode 100644 index 0000000..af0cfaf --- /dev/null +++ b/design/Icones/svg/Remboursement.svg @@ -0,0 +1,126 @@ + + + + + + + + \ No newline at end of file diff --git a/design/Icones/svg/Retrait.svg b/design/Icones/svg/Retrait.svg new file mode 100644 index 0000000..435dcf2 --- /dev/null +++ b/design/Icones/svg/Retrait.svg @@ -0,0 +1,102 @@ + + + + + + + \ No newline at end of file diff --git a/design/Icones/svg/Tax.svg b/design/Icones/svg/Tax.svg new file mode 100644 index 0000000..bb25fed --- /dev/null +++ b/design/Icones/svg/Tax.svg @@ -0,0 +1,1207 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/design/Icones/svg/porte-monaie.svg b/design/Icones/svg/porte-monaie.svg new file mode 100644 index 0000000..b622dc2 --- /dev/null +++ b/design/Icones/svg/porte-monaie.svg @@ -0,0 +1,437 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/design/briefs-illustrations.md b/design/briefs-illustrations.md new file mode 100644 index 0000000..5377eba --- /dev/null +++ b/design/briefs-illustrations.md @@ -0,0 +1,259 @@ +# Briefs d'illustrations — Crowdlending App + +Document de référence pour la génération des visuels de l'application de suivi de portefeuille de crowdlending (prêts participatifs). + +--- + +## 1. Règles communes à toutes les illustrations + +### Format technique +- **Format livrable :** SVG vectoriel avec fond transparent +- **Ratio cible :** Variable selon la page (précisé par illustration) +- **Texte :** Aucun texte intégré dans les illustrations — le texte est géré par l'application +- **Dark mode :** Fond transparent ; les teintes doivent rester lisibles sur fond clair (`#ffffff`) ET fond sombre (`#1a1a2e` environ). Éviter les couleurs trop claires qui disparaissent sur fond blanc, ou trop sombres sur fond sombre. + +### Style global +- **Style dominant :** Flat illustration — formes géométriques épurées, ombres douces mais pas de réalisme photographique +- **Touches semi-réalistes :** Acceptées ponctuellement pour les personnages (Login, Register) ou les objets symboliques (pièces, documents) +- **Trait :** Pas de contour noir dur ; les formes sont délimitées par des variations de couleur ou des ombres légères +- **Ambiance :** Fintech moderne, professionnel, rassurant — pas austère, pas enfantin + +### Palette par page +Chaque page a une teinte dominante distincte, déclinée en 2–3 variations (clair / principal / foncé). Les autres éléments utilisent des neutres doux (gris-bleu, blanc cassé, beige léger). + +--- + +## 2. Page de connexion (`/login`) + +### Concept +L'utilisateur retrouve son portefeuille. Idée centrale : **accès sécurisé à ses données financières personnelles**, sentiment de confiance et de maîtrise. + +### Teinte dominante +**Bleu profond** — `#2563eb` environ, décliné en `#1e40af` (foncé) et `#93c5fd` (clair). + +### Ratio +Portrait 4:5 ou carré 1:1, placé à droite du formulaire de connexion. + +### Éléments visuels à inclure +- Un **écran ou tableau de bord abstrait** en perspective légère (isométrie douce) montrant des courbes de croissance et des barres de graphique — sans données réelles +- Un **bouclier ou cadenas stylisé** intégré naturellement dans la composition (pas en surimpression brutale) +- Des **formes géométriques flottantes** en arrière-plan (cercles, hexagones légers) qui évoquent un réseau de financement participatif +- Optionnel : une **silhouette humaine abstraite** (flat, sans visage détaillé) consultant un écran + +### Ambiance / mots-clés +Confiance · Sécurité · Clarté · Accès personnel · Nuit bleue apaisante + +### À éviter +Cadenas réaliste, icônes de sécurité génériques, fond uni sans composition. + +--- + +## 3. Page d'inscription (`/register`) + +### Concept +Nouveau départ, premier pas vers la croissance de son patrimoine. Idée : **planter une graine financière**, commencer à construire. + +### Teinte dominante +**Vert émeraude** — `#059669` environ, décliné en `#047857` (foncé) et `#6ee7b7` (clair). + +### Ratio +Portrait 4:5 ou carré 1:1, placé à droite du formulaire. + +### Éléments visuels à inclure +- Une **plante ou arbre stylisé en flat** dont les feuilles sont remplacées par des formes évoquant des graphiques (triangles ascendants, petits histogrammes) +- À la base : des **pièces empilées ou un sol géométrique** représentant le capital de départ +- Des **étoiles ou points lumineux** dispersés en arrière-plan, évoquant le réseau de prêteurs/emprunteurs +- Palette de verts chauds avec des accents dorés/jaunes pour les éléments "valeur" + +### Ambiance / mots-clés +Croissance · Optimisme · Premier investissement · Énergie · Nouveau départ + +### À éviter +Personnages trop figuratifs, symboles euro/dollar trop littéraux, composition trop chargée. + +--- + +## 4. Tableau de bord (`/`) — état vide + +### Concept +Illustration affichée quand l'utilisateur n'a pas encore d'investissements. Invitation à commencer. Idée : **un portefeuille vide mais plein de potentiel**. + +### Teinte dominante +**Violet indigo** — `#7c3aed` environ, décliné en `#5b21b6` (foncé) et `#c4b5fd` (clair). + +### Ratio +Paysage 16:9 ou 3:2, centré sous le message d'état vide. + +### Éléments visuels à inclure +- Un **graphique vide ou en pointillés** (axes tracés, courbe absente ou en tirets) qui suggère ce qui pourrait être là +- Des **cartes KPI fantômes** (rectangles avec shimmer ou contour en pointillé) +- Une **boussole ou carte du trésor stylisée** en flat, symbolisant le chemin à parcourir +- Des **petits éléments flottants** : icône +, flèches directionnelles, symboles de croissance + +### Ambiance / mots-clés +Potentiel · Invitation · Légèreté · "Commencez votre voyage" + +--- + +## 5. Page Investissements (`/investissements`) — état vide + +### Concept +Pas encore de prêts enregistrés. Idée : **une salle des coffres vide**, des emplacements qui attendent. + +### Teinte dominante +**Orange ambré** — `#d97706` environ, décliné en `#b45309` (foncé) et `#fcd34d` (clair). + +### Ratio +Paysage 3:2, centré. + +### Éléments visuels à inclure +- Des **dossiers ou cartes de projet vides**, empilés en perspective légère +- Une **ampoule ou boussole** au centre symbolisant l'idée à venir +- Des **lignes de connexion en pointillés** entre des nœuds vides (réseau P2P non encore peuplé) +- Accents dorés sur les éléments "valeur" + +### Ambiance / mots-clés +Attente · Opportunité · Réseau de financement · Structuré + +--- + +## 6. Page Remboursements (`/remboursements`) — état vide + +### Concept +Pas encore de flux reçus. Idée : **un calendrier ou une boîte aux lettres vide**, des flux qui vont arriver. + +### Teinte dominante +**Cyan turquoise** — `#0891b2` environ, décliné en `#0e7490` (foncé) et `#67e8f9` (clair). + +### Ratio +Paysage 3:2, centré. + +### Éléments visuels à inclure +- Un **calendrier stylisé** avec des cases vides ou des dates en pointillés +- Des **flèches de flux entrant** (argent qui arrive vers un portefeuille) +- Des **pièces ou billets flat** en trajectoire parabolique vers un portefeuille central +- Ambiance légère et dynamique (les flux vont arriver) + +### Ambiance / mots-clés +Flux entrant · Calendrier · Anticipation · Régularité + +--- + +## 7. Page Dépôts / Retraits (`/depots-retraits`) — état vide + +### Concept +Pas encore de mouvements enregistrés. Idée : **un compte bancaire neutre**, prêt à recevoir ses premiers flux. + +### Teinte dominante +**Bleu-gris ardoise** — `#475569` environ, décliné en `#1e293b` (foncé) et `#94a3b8` (clair). + +### Ratio +Paysage 3:2, centré. + +### Éléments visuels à inclure +- Un **compte ou portefeuille stylisé** en perspective flat, ouvert et vide +- Des **flèches bidirectionnelles** (entrée et sortie) avec des teintes différentes (vert pour dépôt, rouge doux pour retrait) +- Un **solde "0,00 €" stylisé** en grand format comme élément graphique central (pas de vrai texte — une représentation stylisée) +- Des **cercles concentriques** en arrière-plan suggérant la liquidité + +### Ambiance / mots-clés +Neutralité · Liquidité · Mouvement · Équilibre + +--- + +## 8. Page Fiscalité / 2778-SD (`/2778-sd`) — état vide + +### Concept +Pas encore de données fiscales. Idée : **des documents officiels vierges**, un classeur fiscal ordonné. + +### Teinte dominante +**Rouge corail doux** — `#dc2626` décliné en tons doux `#fee2e2` (fond) et `#991b1b` (accent). + +### Ratio +Paysage 3:2, centré. + +### Éléments visuels à inclure +- Des **formulaires/documents empilés en perspective** (style papier officiel mais stylisé, pas photo) +- Un **calculateur flat** ou une règle de calcul vintage stylisée +- Des **lignes de tableau vides** suggestives (colonnes tracées, lignes en pointillés) +- Un **tampon ou sceau circulaire** stylisé sans texte, symbolisant l'officiel + +### Ambiance / mots-clés +Administration · Rigueur · Documentation · Clarté fiscale + +--- + +## 9. Page Simulation (`/simul`) — illustration header + +### Concept +Projection dans le futur. Idée : **une lunette de visée ou un télescope pointé vers l'horizon financier**. + +### Teinte dominante +**Violet-rose** — `#9333ea` environ, décliné en `#7e22ce` (foncé) et `#e879f9` (clair). + +### Ratio +Paysage 16:9, affiché en haut de page. + +### Éléments visuels à inclure +- Une **courbe ascendante projetée vers le futur**, la partie gauche pleine (passé) et la partie droite en pointillés lumineux (projection) +- Des **étoiles ou constellations** en arrière-plan léger +- Un **horizon abstrait** ou une ligne de fuite perspective +- Des **marqueurs temporels** (points sur la courbe) sans dates réelles + +### Ambiance / mots-clés +Futur · Projection · Vision · Anticipation · Lumière + +--- + +## 10. Pages Settings / MonCompte — illustration section vide + +### Concept +Paramètres non encore configurés. Idée : **un atelier ou tableau de bord de contrôle** vierge. + +### Teinte dominante +**Gris neutre avec accent teal** — fond `#f8fafc`, accent `#0f766e`. + +### Ratio +Carré 1:1, affiché en sidebar ou en état vide de section. + +### Éléments visuels à inclure +- Des **engrenages imbriqués flat** en composition équilibrée (pas le cliché engrenage isolé) +- Des **curseurs et toggles stylisés** en arrière-plan +- Une **palette de couleurs ou pipette** pour la section Apparence +- Des **lignes de connexion** entre les composants symbolisant l'interconnexion des paramètres + +### Ambiance / mots-clés +Contrôle · Personnalisation · Ordre · Maîtrise + +--- + +## 11. Page 404 / Erreur + +### Concept +Page non trouvée. Idée : **un investisseur qui cherche dans le vide**, boussole qui tourne, route qui mène nulle part. + +### Teinte dominante +**Jaune soleil chaleureux** — `#eab308` environ, décliné en `#a16207` (foncé) et `#fef9c3` (clair). + +### Ratio +Carré 1:1 ou portrait 3:4. + +### Éléments visuels à inclure +- Une **boussole flat** dont l'aiguille est en point d'interrogation +- Un **chemin ou route** qui s'arrête abruptement dans le vide +- Des **éléments du tableau de bord qui flottent** de façon désordonnée (cartes KPI, graphiques) comme éparpillés +- Ambiance légère et humoristique, pas anxiogène + +### Ambiance / mots-clés +Humour doux · Égaré · "On vous retrouve" · Légèreté + +--- + +## Notes d'intégration pour le développeur + +Une fois les SVG générés : + +1. **Nettoyage SVG** : Supprimer les métadonnées inutiles (Adobe, Inkscape), vérifier l'absence d'images raster embarquées (balise ``). +2. **Variables CSS** : Remplacer les couleurs hardcodées des éléments neutres (gris, blanc) par `var(--surface)`, `var(--border)`, `var(--text-muted)` pour l'adaptation dark mode automatique. Laisser les couleurs caractéristiques de chaque illustration en valeurs fixes. +3. **Import React** : `import { ReactComponent as IllustrationLogin } from '../assets/illustrations/login.svg'` (Vite : `import LoginIllustration from '../assets/illustrations/login.svg?react'`). +4. **Placement** : Les illustrations d'état vide se placent dans un `div.empty-state` centré, largeur max 320px. Les illustrations de page (login/register) se placent dans la colonne droite du layout split-screen. diff --git a/design/icones platformes/Bienprêter_carré.webp b/design/icones platformes/Bienprêter_carré.webp new file mode 100644 index 0000000..fb95d26 Binary files /dev/null and b/design/icones platformes/Bienprêter_carré.webp differ diff --git a/design/icones platformes/La première brique.png b/design/icones platformes/La première brique.png new file mode 100644 index 0000000..14eb95a Binary files /dev/null and b/design/icones platformes/La première brique.png differ diff --git a/design/icones platformes/Maclear_carré.png b/design/icones platformes/Maclear_carré.png new file mode 100644 index 0000000..c14755c Binary files /dev/null and b/design/icones platformes/Maclear_carré.png differ diff --git a/design/icones platformes/TD Funding_carré.webp b/design/icones platformes/TD Funding_carré.webp new file mode 100644 index 0000000..a872cbd Binary files /dev/null and b/design/icones platformes/TD Funding_carré.webp differ diff --git a/design/icones platformes/anaxago.png b/design/icones platformes/anaxago.png new file mode 100644 index 0000000..4a30cd5 Binary files /dev/null and b/design/icones platformes/anaxago.png differ diff --git a/design/icones platformes/baltis.png b/design/icones platformes/baltis.png new file mode 100644 index 0000000..f2d2c39 Binary files /dev/null and b/design/icones platformes/baltis.png differ diff --git a/design/icones platformes/clubfunding.png b/design/icones platformes/clubfunding.png new file mode 100644 index 0000000..34d19bf Binary files /dev/null and b/design/icones platformes/clubfunding.png differ diff --git a/design/icones platformes/enky.png b/design/icones platformes/enky.png new file mode 100644 index 0000000..d5cc22c Binary files /dev/null and b/design/icones platformes/enky.png differ diff --git a/design/icones platformes/fundora.png b/design/icones platformes/fundora.png new file mode 100644 index 0000000..1204fc5 Binary files /dev/null and b/design/icones platformes/fundora.png differ diff --git a/design/icones platformes/indemo_eu_logo.jpg b/design/icones platformes/indemo_eu_logo.jpg new file mode 100644 index 0000000..b4ae290 Binary files /dev/null and b/design/icones platformes/indemo_eu_logo.jpg differ diff --git a/design/icones platformes/swaper.webp b/design/icones platformes/swaper.webp new file mode 100644 index 0000000..d54d5d2 Binary files /dev/null and b/design/icones platformes/swaper.webp differ diff --git a/design/icones platformes/tantiem.webp b/design/icones platformes/tantiem.webp new file mode 100644 index 0000000..cc8e0ba Binary files /dev/null and b/design/icones platformes/tantiem.webp differ diff --git a/design/icones platformes/vancelian-logo.png b/design/icones platformes/vancelian-logo.png new file mode 100644 index 0000000..cd757a7 Binary files /dev/null and b/design/icones platformes/vancelian-logo.png differ diff --git a/design/icones platformes/vancelian.png b/design/icones platformes/vancelian.png new file mode 100644 index 0000000..13ab6cc Binary files /dev/null and b/design/icones platformes/vancelian.png differ diff --git a/design/logos plateforme/logo_Clubfunding.png b/design/logos plateforme/logo_Clubfunding.png new file mode 100644 index 0000000..3996403 Binary files /dev/null and b/design/logos plateforme/logo_Clubfunding.png differ diff --git a/design/logos plateforme/logo_anaxago.svg b/design/logos plateforme/logo_anaxago.svg new file mode 100644 index 0000000..ef37150 --- /dev/null +++ b/design/logos plateforme/logo_anaxago.svg @@ -0,0 +1,16 @@ + + + \ No newline at end of file diff --git a/design/logos plateforme/logo_baltis.svg b/design/logos plateforme/logo_baltis.svg new file mode 100644 index 0000000..8e29643 --- /dev/null +++ b/design/logos plateforme/logo_baltis.svg @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/design/logos plateforme/logo_bienpreter.svg b/design/logos plateforme/logo_bienpreter.svg new file mode 100644 index 0000000..84068cc --- /dev/null +++ b/design/logos plateforme/logo_bienpreter.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/design/logos plateforme/logo_enky.svg b/design/logos plateforme/logo_enky.svg new file mode 100644 index 0000000..3f7e1d2 --- /dev/null +++ b/design/logos plateforme/logo_enky.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/design/logos plateforme/logo_fundora.svg b/design/logos plateforme/logo_fundora.svg new file mode 100644 index 0000000..7b71281 --- /dev/null +++ b/design/logos plateforme/logo_fundora.svg @@ -0,0 +1,26 @@ + + + + + + + + + + \ No newline at end of file diff --git a/design/logos plateforme/logo_indemo.svg b/design/logos plateforme/logo_indemo.svg new file mode 100644 index 0000000..0644cb1 --- /dev/null +++ b/design/logos plateforme/logo_indemo.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/design/logos plateforme/logo_lapermierebrique.svg b/design/logos plateforme/logo_lapermierebrique.svg new file mode 100644 index 0000000..ff866e8 --- /dev/null +++ b/design/logos plateforme/logo_lapermierebrique.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/design/logos plateforme/logo_maclear.svg b/design/logos plateforme/logo_maclear.svg new file mode 100644 index 0000000..e4abd31 --- /dev/null +++ b/design/logos plateforme/logo_maclear.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/design/logos plateforme/logo_swaper.svg b/design/logos plateforme/logo_swaper.svg new file mode 100644 index 0000000..04d8f12 --- /dev/null +++ b/design/logos plateforme/logo_swaper.svg @@ -0,0 +1 @@ +Artboard 1 \ No newline at end of file diff --git a/design/logos plateforme/logo_tantiem.svg b/design/logos plateforme/logo_tantiem.svg new file mode 100644 index 0000000..bab497d --- /dev/null +++ b/design/logos plateforme/logo_tantiem.svg @@ -0,0 +1,30 @@ + + + + + + + + + diff --git a/design/logos plateforme/logo_triple_dragon_funding.svg b/design/logos plateforme/logo_triple_dragon_funding.svg new file mode 100644 index 0000000..41ae822 --- /dev/null +++ b/design/logos plateforme/logo_triple_dragon_funding.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/design/logos plateforme/logo_vancelian.svg b/design/logos plateforme/logo_vancelian.svg new file mode 100644 index 0000000..358c989 --- /dev/null +++ b/design/logos plateforme/logo_vancelian.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/design/screenshot/Dépôts - Retraits/Mouvements.png b/design/screenshot/Dépôts - Retraits/Mouvements.png new file mode 100644 index 0000000..9064969 Binary files /dev/null and b/design/screenshot/Dépôts - Retraits/Mouvements.png differ diff --git a/design/screenshot/Dépôts - Retraits/Plateformes.png b/design/screenshot/Dépôts - Retraits/Plateformes.png new file mode 100644 index 0000000..5916dde Binary files /dev/null and b/design/screenshot/Dépôts - Retraits/Plateformes.png differ diff --git a/design/screenshot/Dépôts - Retraits/Vision mensuelle.png b/design/screenshot/Dépôts - Retraits/Vision mensuelle.png new file mode 100644 index 0000000..e0a7758 Binary files /dev/null and b/design/screenshot/Dépôts - Retraits/Vision mensuelle.png differ diff --git a/design/screenshot/Fiscalité/Fiscalité.png b/design/screenshot/Fiscalité/Fiscalité.png new file mode 100644 index 0000000..3010721 Binary files /dev/null and b/design/screenshot/Fiscalité/Fiscalité.png differ diff --git a/design/screenshot/Investissements/Capture 0.PNG b/design/screenshot/Investissements/Capture 0.PNG new file mode 100644 index 0000000..626d639 Binary files /dev/null and b/design/screenshot/Investissements/Capture 0.PNG differ diff --git a/design/screenshot/Investissements/Capture 1.PNG b/design/screenshot/Investissements/Capture 1.PNG new file mode 100644 index 0000000..5604b92 Binary files /dev/null and b/design/screenshot/Investissements/Capture 1.PNG differ diff --git a/design/screenshot/Investissements/Investissements.png b/design/screenshot/Investissements/Investissements.png new file mode 100644 index 0000000..6fb731b Binary files /dev/null and b/design/screenshot/Investissements/Investissements.png differ diff --git a/design/screenshot/Investissements/Plateformes.png b/design/screenshot/Investissements/Plateformes.png new file mode 100644 index 0000000..2f783fa Binary files /dev/null and b/design/screenshot/Investissements/Plateformes.png differ diff --git a/design/screenshot/Investissements/Vision mensuelle.png b/design/screenshot/Investissements/Vision mensuelle.png new file mode 100644 index 0000000..0180257 Binary files /dev/null and b/design/screenshot/Investissements/Vision mensuelle.png differ diff --git a/design/screenshot/Paramètres/Plateformes.png b/design/screenshot/Paramètres/Plateformes.png new file mode 100644 index 0000000..9840d94 Binary files /dev/null and b/design/screenshot/Paramètres/Plateformes.png differ diff --git a/design/screenshot/Remboursements/Plateformes.png b/design/screenshot/Remboursements/Plateformes.png new file mode 100644 index 0000000..0043def Binary files /dev/null and b/design/screenshot/Remboursements/Plateformes.png differ diff --git a/design/screenshot/Remboursements/Projections Echéances.png b/design/screenshot/Remboursements/Projections Echéances.png new file mode 100644 index 0000000..42dd1ed Binary files /dev/null and b/design/screenshot/Remboursements/Projections Echéances.png differ diff --git a/design/screenshot/Remboursements/Vision mensuelle.png b/design/screenshot/Remboursements/Vision mensuelle.png new file mode 100644 index 0000000..d58556b Binary files /dev/null and b/design/screenshot/Remboursements/Vision mensuelle.png differ diff --git a/design/screenshot/Tableau de bord/Capture 0.PNG b/design/screenshot/Tableau de bord/Capture 0.PNG new file mode 100644 index 0000000..442becd Binary files /dev/null and b/design/screenshot/Tableau de bord/Capture 0.PNG differ diff --git a/design/screenshot/Tableau de bord/Capture 1.PNG b/design/screenshot/Tableau de bord/Capture 1.PNG new file mode 100644 index 0000000..bc362c7 Binary files /dev/null and b/design/screenshot/Tableau de bord/Capture 1.PNG differ diff --git a/design/screenshot/Tableau de bord/Tableau de bord.png b/design/screenshot/Tableau de bord/Tableau de bord.png new file mode 100644 index 0000000..2af3de7 Binary files /dev/null and b/design/screenshot/Tableau de bord/Tableau de bord.png differ diff --git a/design/screenshot/User Menu/User Menu.png b/design/screenshot/User Menu/User Menu.png new file mode 100644 index 0000000..8ba7f9c Binary files /dev/null and b/design/screenshot/User Menu/User Menu.png differ diff --git a/design/screenshot/selecteur/Catégorie de plateforme.PNG b/design/screenshot/selecteur/Catégorie de plateforme.PNG new file mode 100644 index 0000000..a2226ed Binary files /dev/null and b/design/screenshot/selecteur/Catégorie de plateforme.PNG differ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c3ed0a5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,46 @@ +version: "3.9" + +services: + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: crowdlending-backend + restart: unless-stopped + # Port exposé uniquement en dev local — en prod, nginx proxie en interne + # ports: + # - "4000:4000" + env_file: + - .env + environment: + NODE_ENV: production + PORT: 4000 + DB_PATH: /app/data/crowdlending.db + UPLOAD_DIR: /app/uploads + volumes: + - backend_data:/app/data + - backend_uploads:/app/uploads + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://localhost:4000/api/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + args: + VITE_API_URL: /api + container_name: crowdlending-frontend + restart: unless-stopped + ports: + - "8080:80" + depends_on: + backend: + condition: service_healthy + +volumes: + backend_data: + backend_uploads: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..755be19 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +.env +.env.* +npm-debug.log +*.log diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..1f6f14d --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist +.env.local diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..2c60305 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,14 @@ +FROM node:20-alpine AS build +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm install +COPY . . +ARG VITE_API_URL=/api +ENV VITE_API_URL=$VITE_API_URL +RUN npm run build + +FROM nginx:1.27-alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..b74766b --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + + Crowdlending Tracker + + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..760c123 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,30 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Static assets — long cache + location /assets/ { + expires 30d; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + # API proxy to the backend service (docker-compose internal DNS) + location /api/ { + proxy_pass http://backend:4000/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 120s; + client_max_body_size 15M; + } + + # SPA fallback + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..6e94648 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1823 @@ +{ + "name": "crowdlending-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "crowdlending-frontend", + "version": "0.1.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.2", + "xlsx": "^0.18.5" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.1", + "vite": "^5.4.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.25", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.25.tgz", + "integrity": "sha512-QO/VHsXCQdnzADMfmkeOPvHdIAkoB7i0/rGjINPJEetLx75hNttVWGQ/jycHUDP9zZ9rupbm60WRxcwViB0MiA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001791", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", + "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.349", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz", + "integrity": "sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.13", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", + "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..5948b72 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,21 @@ +{ + "name": "crowdlending-frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.2", + "xlsx": "^0.18.5" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.1", + "vite": "^5.4.0" + } +} diff --git a/frontend/public/icon.svg b/frontend/public/icon.svg new file mode 100644 index 0000000..63ed91b --- /dev/null +++ b/frontend/public/icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..81a726b --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,64 @@ +import { Routes, Route, Navigate } from 'react-router-dom'; +import { useAuth } from './context/AuthContext.jsx'; +import Login from './pages/Login.jsx'; +import Register from './pages/Register.jsx'; +import Layout from './components/Layout.jsx'; +import Dashboard from './pages/Dashboard.jsx'; +import DepotsRetraits from './pages/DepotsRetraits.jsx'; +import Investissements from './pages/Investissements.jsx'; +import InvestissementDetail from './pages/InvestissementDetail.jsx'; +import Remboursements from './pages/Remboursements.jsx'; +import SimulRemboursements from './pages/SimulRemboursements.jsx'; +import TaxReport from './pages/TaxReport.jsx'; +import Settings from './pages/Settings.jsx'; +import MonCompte from './pages/MonCompte.jsx'; +import Admin from './pages/Admin.jsx'; +import AdminPlateformes from './pages/AdminPlateformes.jsx'; +import AdminFiscalite from './pages/AdminFiscalite.jsx'; +import Aide from './pages/Aide.jsx'; +import PlatformeProfile from './pages/PlatformeProfile.jsx'; +import Plateformes from './pages/Plateformes.jsx'; + +function Protected({ children }) { + const { token, loading } = useAuth(); + if (loading) return
Chargement…
; + if (!token) return ; + return children; +} + +function AdminOnly({ children }) { + const { isAdmin, loading } = useAuth(); + if (loading) return
Chargement…
; + if (!isAdmin) return ; + return children; +} + +export default function App() { + return ( + + } /> + } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} diff --git a/frontend/src/api.js b/frontend/src/api.js new file mode 100644 index 0000000..a97f965 --- /dev/null +++ b/frontend/src/api.js @@ -0,0 +1,67 @@ +// Tiny fetch wrapper. Reads token + investisseurId from localStorage. + +const BASE = import.meta.env.VITE_API_URL || '/api'; + +function authHeaders() { + const token = localStorage.getItem('cl_token'); + const investisseurId = localStorage.getItem('cl_investisseur_id'); + const h = {}; + if (token) h['Authorization'] = `Bearer ${token}`; + if (investisseurId) h['X-Investisseur-Id'] = investisseurId; + return h; +} + +async function handle(res) { + if (res.status === 204) return null; + 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 || 'Request failed'; + const err = new Error(msg); + err.status = res.status; + err.details = body && body.details; + throw err; + } + return body; +} + +export const api = { + get: (path, params) => { + 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); + }, + post: (path, body) => + fetch(BASE + path, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify(body), + }).then(handle), + put: (path, body) => + fetch(BASE + path, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify(body), + }).then(handle), + patch: (path, body) => + fetch(BASE + path, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify(body), + }).then(handle), + del: (path) => + fetch(BASE + path, { method: 'DELETE', headers: authHeaders() }).then(handle), + upload: (path, formData) => + fetch(BASE + path, { method: 'POST', body: formData, headers: authHeaders() }).then(handle), + blob: (path) => + fetch(BASE + path, { headers: authHeaders() }).then(async res => { + if (!res.ok) { const t = await res.text(); throw new Error(t || res.statusText); } + return res.blob(); + }), + exportUrl: (path, params) => { + const qs = params ? '?' + new URLSearchParams(params).toString() : ''; + return BASE + path + qs; + }, +}; diff --git a/frontend/src/components/CapitalMensuelTable.jsx b/frontend/src/components/CapitalMensuelTable.jsx new file mode 100644 index 0000000..0d155bb --- /dev/null +++ b/frontend/src/components/CapitalMensuelTable.jsx @@ -0,0 +1,351 @@ +import { useMemo, useState } from 'react'; +import { fmtEUR } from '../utils/format.js'; + +const MOIS_LONG = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre']; + +/* ── Helpers dates ───────────────────────────────────────────────── */ +function endOfMonth(Y, M) { + const d = new Date(Y, M, 0); // day 0 of month M+1 = last day of month M + return `${Y}-${String(M).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; +} +function startOfMonth(Y, M) { + return `${Y}-${String(M).padStart(2,'0')}-01`; +} + +function ChevronDown({ size = 10 }) { + return ( + + + + ); +} + +/* ── Composant ───────────────────────────────────────────────────── */ +export default function CapitalMensuelTable({ allRows, allRembs, allReinvests, plats, expandButton }) { + const currentYear = new Date().getFullYear(); + const currentMonth = new Date().getMonth() + 1; + + const [annee, setAnnee] = useState(currentYear); + + /* ── Toggle consolidation détenteurs ── */ + const [groupByNom, setGroupByNom] = useState(() => { + try { return localStorage.getItem('cl_tip_group_by_nom') === 'true'; } catch { return false; } + }); + const toggleGroupByNom = () => { + setGroupByNom(v => { + const next = !v; + try { localStorage.setItem('cl_tip_group_by_nom', String(next)); } catch {} + return next; + }); + }; + + /* ── Années disponibles ── */ + const availableYears = useMemo(() => { + const set = new Set(allRows.map(r => r.date_souscription?.slice(0,4)).filter(Boolean)); + return [...set].map(Number).sort((a,b) => a - b); + }, [allRows]); + + /* ── Precompute : reinvests et capital_remb par investissement ── */ + const reinvestByInv = useMemo(() => { + const map = {}; + for (const rv of allReinvests) { + const id = rv.investissement_id; + if (!id) continue; + if (!map[id]) map[id] = []; + map[id].push({ date: rv.date_reinvestissement?.slice(0,10), montant: rv.montant || 0 }); + } + return map; + }, [allReinvests]); + + const capRembByInv = useMemo(() => { + const map = {}; + for (const rb of allRembs) { + const id = rb.investissement_id; + if (!id || rb.type !== 'normal') continue; + if (!map[id]) map[id] = []; + map[id].push({ date: rb.date_remb?.slice(0,10), capital: rb.capital || 0 }); + } + return map; + }, [allRembs]); + + const lastRembDateMap = useMemo(() => { + const map = {}; + for (const rb of allRembs) { + const id = rb.investissement_id; + const d = rb.date_remb?.slice(0,10); + if (!id || !d) continue; + if (!map[id] || d > map[id]) map[id] = d; + } + return map; + }, [allRembs]); + + /* ── Calcul capital encours par plateforme par mois ───────────── + * Pour chaque mois M : + * - L'investissement est actif si souscrit avant fin M + * ET (statut actif aujourd'hui OU date_fin >= début M) + * - capital = montant_investi + reinvests_≤_finM − capital_remboursé_≤_finM + * ─────────────────────────────────────────────────────────────── */ + const { grid, multiDetenteur } = useMemo(() => { + if (!allRows.length) return { grid: null, multiDetenteur: false }; + + const ACTIVE = ['en_cours', 'en_retard', 'procedure']; + + // Index plateformes par id (pour nom + detenteur) + const platMap = {}; + for (const p of plats) platMap[p.id] = p; + + // Pour chaque investissement, capital encours au end of month M + const getCapitalAtEndOfMonth = (inv, Y, M) => { + const endM = endOfMonth(Y, M); + if (inv.date_souscription > endM) return 0; + const startM = startOfMonth(Y, M); + const isActive = ACTIVE.includes(inv.statut) || + ((inv.date_cible || lastRembDateMap[inv.id] || null) >= startM); + if (!isActive) return 0; + + const reinvM = (reinvestByInv[inv.id] || []) + .filter(rv => rv.date && rv.date <= endM) + .reduce((s, rv) => s + rv.montant, 0); + + const capRembM = (capRembByInv[inv.id] || []) + .filter(rb => rb.date && rb.date <= endM) + .reduce((s, rb) => s + rb.capital, 0); + + return Math.max(0, inv.montant_investi + reinvM - capRembM); + }; + + // Agréger par plateforme (id) + const byPlat = {}; + for (const inv of allRows) { + const pid = inv.plateforme_id; + if (!byPlat[pid]) { + const p = platMap[pid] || {}; + byPlat[pid] = { + id: pid, + nom: inv.plateforme_nom || p.nom || '—', + investisseur_id: p.investisseur_id ?? inv.investisseur_id ?? null, + detenteur_nom: inv.plateforme_detenteur_nom || null, + months: Array(12).fill(0), + }; + } + const row = byPlat[pid]; + for (let m = 1; m <= 12; m++) { + row.months[m-1] += getCapitalAtEndOfMonth(inv, annee, m); + } + } + + const allPlats = Object.values(byPlat).filter(p => p.months.some(v => v > 0)); + + // Détection multi-détenteur sur données brutes + const multi = new Set(allPlats.map(p => p.investisseur_id).filter(v => v != null)).size > 1; + + // Consolidation par nom si demandée + let rows; + if (groupByNom && multi) { + const byNom = {}; + for (const row of allPlats) { + if (!byNom[row.nom]) { + byNom[row.nom] = { id: row.nom, nom: row.nom, investisseur_id: null, detenteur_nom: null, months: [...row.months] }; + } else { + for (let i = 0; i < 12; i++) byNom[row.nom].months[i] += row.months[i]; + } + } + rows = Object.values(byNom); + } else { + rows = allPlats; + } + + rows = rows + .filter(p => p.months.some(v => v > 0)) + .sort((a,b) => b.months.reduce((s,v) => s+v, 0) - a.months.reduce((s,v) => s+v, 0)); + + return { grid: rows, multiDetenteur: multi }; + }, [allRows, annee, reinvestByInv, capRembByInv, lastRembDateMap, plats, groupByNom]); + + /* ── Totaux et moyennes ── */ + const stats = useMemo(() => { + if (!grid) return null; + + const monthTotals = Array.from({ length: 12 }, (_, i) => + grid.reduce((s, row) => s + row.months[i], 0)); + + const grandTotal = monthTotals.reduce((s, v) => s + v, 0); + + // Moyenne : average of non-zero months per platform + const platMoyennes = grid.map(row => { + const nonZero = row.months.filter(v => v > 0); + return nonZero.length ? nonZero.reduce((s,v) => s+v, 0) / nonZero.length : 0; + }); + + const totalMoyenne = platMoyennes.reduce((s,v) => s+v, 0); + + const platPoids = platMoyennes.map(m => totalMoyenne > 0 ? (m / totalMoyenne) * 100 : 0); + + const monthMoyennes = Array.from({ length: 12 }, (_, i) => + grid.reduce((s, row) => s + row.months[i], 0)); + + const nonZeroMonthTotals = monthTotals.filter(v => v > 0); + const globalMoyenne = nonZeroMonthTotals.length + ? nonZeroMonthTotals.reduce((s,v) => s+v, 0) / nonZeroMonthTotals.length + : 0; + + return { monthTotals, grandTotal, platMoyennes, platPoids, totalMoyenne, monthMoyennes, globalMoyenne }; + }, [grid]); + + /* ── Sélecteur d'années ── */ + const [windowStart, setWindowStart] = useState(() => { + const idx = availableYears.indexOf(currentYear); + return Math.max(0, Math.min(Math.max(0, availableYears.length - 3), (idx >= 0 ? idx : availableYears.length - 1) - 1)); + }); + const visibleYears = availableYears.length ? availableYears.slice(windowStart, windowStart + 3) : [annee]; + const canPrev = windowStart > 0; + const canNext = windowStart + 3 < availableYears.length; + + /* ── Rendu ─────────────────────────────────────────────────────── */ + return ( +
+ + {/* ── Header ── */} +
+
+
+ + {`Capital investi · ${annee}`} + +
+
+ {stats ? fmtEUR(stats.globalMoyenne) : '—'} + moy. mensuelle +
+
+ + {/* Sélecteur d'années */} +
+
+ + {visibleYears.map(y => ( + + ))} + + + {expandButton} +
+
+
+ + {/* ── Table ── */} + {!grid || grid.length === 0 ? ( +
+ Aucun capital investi pour {annee}. +
+ ) : ( +
+ + + + + + + + {MOIS_LONG.map((m, i) => ( + + ))} + + + + + + + {grid.map((plat, pi) => ( + + + {plat.months.map((v, mi) => ( + + ))} + + + + ))} + + + + + + {stats.monthTotals.map((v, i) => ( + + ))} + + + +
+ {annee} + +
+ + Plateforme + {multiDetenteur && ( + + )} + + + {m} + MoyennePoids
+ {plat.nom} + {!groupByNom && multiDetenteur && plat.detenteur_nom && ( + + {plat.detenteur_nom} + + )} + + {v > 0 ? fmtEUR(v) : } + + {stats.platMoyennes[pi] > 0 ? fmtEUR(stats.platMoyennes[pi]) : } + + {stats.platPoids[pi] > 0 ? ( +
+
+
+
+ + {stats.platPoids[pi].toFixed(1)} % + +
+ ) : } +
Toutes les plateformes + {v > 0 ? fmtEUR(v) : } + {fmtEUR(stats.globalMoyenne)} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/CategorySelect.jsx b/frontend/src/components/CategorySelect.jsx new file mode 100644 index 0000000..959e813 --- /dev/null +++ b/frontend/src/components/CategorySelect.jsx @@ -0,0 +1,196 @@ +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { api } from '../api.js'; + +/** + * CategorySelect — multi-select with checkboxes + inline "Add category" + * + * Props: + * selected : number[] — ids sélectionnés + * onChange : (ids: number[]) => void + * categories : { id, nom }[] — liste complète fournie par le parent + * onCategoryAdded : ({ id, nom }) => void — appelé après création inline + * + * Le dropdown est rendu en position:fixed (calculé depuis getBoundingClientRect) + * pour échapper au overflow:auto des modales parentes. + */ +export default function CategorySelect({ selected = [], onChange, categories = [], onCategoryAdded }) { + const [open, setOpen] = useState(false); + const [adding, setAdding] = useState(false); + const [newName, setNewName] = useState(''); + const [err, setErr] = useState(null); + const [busy, setBusy] = useState(false); + const [dropPos, setDropPos] = useState({ top: 0, left: 0, width: 0 }); + + const wrapRef = useRef(null); + const triggerRef = useRef(null); + + /* ── Position fixe calculée à chaque ouverture ───────────────── */ + useLayoutEffect(() => { + if (!open || !triggerRef.current) return; + const rect = triggerRef.current.getBoundingClientRect(); + setDropPos({ + top: rect.bottom + 4, + left: rect.left, + width: rect.width, + }); + }, [open]); + + /* ── Fermeture : clic extérieur + scroll + resize ────────────── */ + useEffect(() => { + if (!open) return; + const close = (e) => { + if (wrapRef.current?.contains(e.target)) return; + // Exclure aussi le dropdown lui-même (rendu en fixed hors du wrap) + const drop = document.getElementById('cat-select-dropdown-portal'); + if (drop?.contains(e.target)) return; + setOpen(false); + }; + const closeOnScroll = (e) => { + const drop = document.getElementById('cat-select-dropdown-portal'); + if (drop?.contains(e.target)) return; + setOpen(false); + }; + + document.addEventListener('mousedown', close); + window.addEventListener('scroll', closeOnScroll, true); + window.addEventListener('resize', closeOnScroll); + return () => { + document.removeEventListener('mousedown', close); + window.removeEventListener('scroll', closeOnScroll, true); + window.removeEventListener('resize', closeOnScroll); + }; + }, [open]); + + const toggle = (id) => { + onChange(selected.includes(id) ? selected.filter(x => x !== id) : [...selected, id]); + }; + + const addCategory = async (e) => { + e.preventDefault(); + if (!newName.trim()) return; + setBusy(true); setErr(null); + try { + const cat = await api.post('/categories', { nom: newName.trim() }); + onCategoryAdded(cat); + onChange([...selected, cat.id]); + setNewName(''); + setAdding(false); + } catch (e) { + setErr(e.message); + } finally { + setBusy(false); + } + }; + + /* ── Label du bouton déclencheur ─────────────────────────────── */ + const label = (() => { + if (selected.length === 0) return "Aucune catégorie d'investissement"; + const names = categories.filter(c => selected.includes(c.id)).map(c => c.nom); + if (names.length <= 2) return names.join(', '); + return `${names.length} catégories d'invest.`; + })(); + + /* ── Dropdown rendu en position:fixed ────────────────────────── */ + const dropdown = open ? ( +
+ {/* Liste des catégories */} + {categories.length === 0 && ( +
Aucune catégorie d'investissement disponible
+ )} + {categories.map(cat => { + const checked = selected.includes(cat.id); + return ( + + ); + })} + + {/* Séparateur + ajout */} +
+ + {!adding ? ( + + ) : ( +
+ setNewName(e.target.value)} + placeholder="Nom de la catégorie d'investissement" + maxLength={100} + /> +
+ + +
+ {err &&
{err}
} +
+ )} +
+ ) : null; + + return ( + <> +
+ +
+ + {/* Dropdown rendu hors du wrap pour échapper à overflow:auto */} + {dropdown} + + ); +} diff --git a/frontend/src/components/Cerfa2042Preview.jsx b/frontend/src/components/Cerfa2042Preview.jsx new file mode 100644 index 0000000..61485ca --- /dev/null +++ b/frontend/src/components/Cerfa2042Preview.jsx @@ -0,0 +1,349 @@ +import { useEffect, useState } from 'react'; +import { api } from '../api.js'; + +const MOIS_LABELS = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre']; +const MOIS_NUMS = ['01','02','03','04','05','06','07','08','09','10','11','12']; +const r2 = v => Math.round((v ?? 0) * 100) / 100; +const fmtN = v => Number(v).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +const fmtI = v => Number(v).toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 }); +const fullName = l => { const n = l.investisseur_nom ?? ''; const p = l.investisseur_prenom ?? ''; return p && n.startsWith(p) ? n : [p, n].filter(Boolean).join(' '); }; + +const BADGE_AUTO = { text: 'déclaration automatique', bg: 'rgba(22,163,74,0.1)', color: '#16a34a' }; +const BADGE_DECL = { text: 'à déclarer', bg: 'rgba(239,68,68,0.1)', color: '#dc2626' }; + +function BadgeTag({ badge }) { + if (!badge) return null; + return ( + + {badge.text} + + ); +} + +/* breakdown item: { nom, val, badge } */ +function Case2042({ code, label, note, value, breakdown }) { + if (!value) return null; + return ( +
+
+
{code}
+
+
{label}
+ {note &&
{note}
} +
+
{fmtI(value)} €
+
+ {breakdown && breakdown.length > 0 && ( +
+ {breakdown.map((p, i) => ( +
+ + └ {p.nom} + + + {fmtI(p.val)} € +
+ ))} +
+ )} +
+ ); +} + +export default function Cerfa2042Preview({ annee, activeView, pfoAssujetti }) { + const LS_EXCL = 'cl_2778_excluded_plats'; + + const [data2561, setData2561] = useState(null); + const [data2778, setData2778] = useState(null); + const [pfuList, setPfuList] = useState([]); + const [excluded, setExcluded] = useState(() => { + try { return new Set(JSON.parse(localStorage.getItem(LS_EXCL)) ?? []); } + catch { return new Set(); } + }); + const [loading, setLoading] = useState(true); + const [view, setView] = useState('matrice'); + const [filterMode, setFilterMode] = useState('all'); // 'all' | 'auto' | 'decl' + + useEffect(() => { + setLoading(true); + const scopeParams = activeView === 'all' ? { scope: 'all' } : {}; + Promise.all([ + api.get('/taxreport/cerfa2561', { annee, ...scopeParams }), + pfuList.length === 0 ? api.get('/pfu') : Promise.resolve(null), + api.get('/taxreport/2778', { annee, ...scopeParams }), + ]).then(([d2561, pfu, d2778]) => { + setData2561(d2561); + if (pfu) setPfuList(pfu); + if (d2778) setData2778(d2778); + }) + .finally(() => setLoading(false)); + }, [annee, activeView]); // eslint-disable-line + + const frLignes = (data2561?.lignes ?? []).filter(l => l.domiciliation === 'FR'); + const platEtr = (data2778?.plateformes ?? []).filter(p => !excluded.has(p.id)); + + const ratesForYear = () => { + const sorted = [...pfuList].sort((a, b) => b.annee - a.annee); + const m = sorted.find(r => r.annee <= Number(annee)); + if (!m) return { pfo: 0.128, csg: 0.106, crds: 0.005, solidarite: 0.075 }; + return { pfo: (m.impot_revenu ?? 12.8) / 100, csg: (m.csg ?? 10.6) / 100, crds: (m.crds ?? 0.5) / 100, solidarite: (m.solidarite ?? 7.5) / 100 }; + }; + const rates = ratesForYear(); + const totalTaxRate = rates.pfo + rates.csg + rates.crds + rates.solidarite; + + /* ── Suivi mensuel combiné ── */ + const matriceView = ( +
+ {loading &&
Chargement…
} + {!loading && frLignes.length === 0 && platEtr.length === 0 && ( +

Aucune donnée pour {annee}.

+ )} + {!loading && (frLignes.length > 0 || platEtr.length > 0) && ( +
+
+

Suivi mensuel des intérêts bruts

+ Revenus {annee} — toutes plateformes +
+ + + + + {MOIS_LABELS.map(m => ( + + ))} + + + + + {frLignes.length > 0 && ( + + + + )} + {frLignes.map(l => { + const total = r2(Object.values(l.mois ?? {}).reduce((s, m) => s + (m.interets_bruts ?? 0), 0)); + return ( + + + {MOIS_NUMS.map(m => { + const v = l.mois?.[m]?.interets_bruts ?? 0; + return ; + })} + + + ); + })} + {frLignes.length > 0 && ( + + + {MOIS_NUMS.map(m => { + const v = r2(frLignes.reduce((s, l) => s + (l.mois?.[m]?.interets_bruts ?? 0), 0)); + return ; + })} + + + )} + {platEtr.length > 0 && ( + + + + )} + {platEtr.map(p => { + const total = r2(Object.values(p.mois ?? {}).reduce((s, v) => s + v, 0)); + return ( + + + {MOIS_NUMS.map(m => { + const v = p.mois?.[m] ?? 0; + return ; + })} + + + ); + })} + + + {platEtr.length > 0 && ( + + + {MOIS_NUMS.map(m => { + const v = r2(platEtr.reduce((s, p) => s + (p.mois?.[m] ?? 0), 0)); + return ; + })} + + + )} + + + {MOIS_NUMS.map(m => { + const v = r2(frLignes.reduce((s, l) => s + (l.mois?.[m]?.interets_bruts ?? 0), 0) + platEtr.reduce((s, p) => s + (p.mois?.[m] ?? 0), 0)); + return ; + })} + + + {/* Taux total prélevé */} + + + {MOIS_NUMS.map(m => { + const brut = r2(frLignes.reduce((s, l) => s + (l.mois?.[m]?.interets_bruts ?? 0), 0) + platEtr.reduce((s, p) => s + (p.mois?.[m] ?? 0), 0)); + const prelevFR = r2(frLignes.reduce((s, l) => s + ((l.mois?.[m]?.prelev_sociaux ?? 0) + (l.mois?.[m]?.prelev_forfaitaire ?? 0)), 0)); + const prelevEtr = r2(platEtr.reduce((s, p) => s + (p.mois?.[m] ?? 0), 0) * totalTaxRate); + const taux = brut > 0 ? ((prelevFR + prelevEtr) / brut * 100).toFixed(1) + ' %' : '—'; + return ; + })} + + + {/* Total intérêt net */} + + + {MOIS_NUMS.map(m => { + const netFR = r2(frLignes.reduce((s, l) => { + const mo = l.mois?.[m]; + return s + ((mo?.interets_bruts ?? 0) - (mo?.prelev_sociaux ?? 0) - (mo?.prelev_forfaitaire ?? 0)); + }, 0)); + const netEtr = r2(platEtr.reduce((s, p) => s + (p.mois?.[m] ?? 0), 0) * (1 - totalTaxRate)); + const net = r2(netFR + netEtr); + return ; + })} + + + + + + +
Plateforme — Détenteur{m}Total
+ Plateformes françaises — déclaration automatique +
+ {l.plateforme_nom} + — {fullName(l)} + 0 ? 'var(--text)' : 'var(--text-muted)' }}>{v > 0 ? fmtN(v) + ' €' : '—'}{total > 0 ? fmtN(total) + ' €' : '—'}
Total Plateformes françaises (brut) 0 ? '#16a34a' : 'var(--text-muted)' }}>{v > 0 ? fmtN(v) + ' €' : '—'} + {fmtN(r2(frLignes.reduce((s, l) => s + r2(Object.values(l.mois ?? {}).reduce((ss, m) => ss + (m.interets_bruts ?? 0), 0)), 0)))} € +
+ Plateformes étrangères — à déclarer +
+ {p.nom} + {(p.investisseur_nom || p.investisseur_prenom) && ( + — {fullName(p)} + )} + 0 ? 'var(--text)' : 'var(--text-muted)' }}>{v > 0 ? fmtN(v) + ' €' : '—'}{total > 0 ? fmtN(total) + ' €' : '—'}
Total Plateformes étrangères (brut) 0 ? '#dc2626' : 'var(--text-muted)' }}>{v > 0 ? fmtN(v) + ' €' : '—'} + {fmtN(r2(platEtr.reduce((s, p) => s + r2(Object.values(p.mois ?? {}).reduce((ss, v) => ss + v, 0)), 0)))} € +
Total général (brut) 0 ? 'var(--text)' : 'var(--text-muted)' }}>{v > 0 ? fmtN(v) + ' €' : '—'} + {fmtN(r2( + frLignes.reduce((s, l) => s + r2(Object.values(l.mois ?? {}).reduce((ss, m) => ss + (m.interets_bruts ?? 0), 0)), 0) + + platEtr.reduce((s, p) => s + r2(Object.values(p.mois ?? {}).reduce((ss, v) => ss + v, 0)), 0) + ))} € +
+ Taux total prélevé ({(totalTaxRate * 100).toFixed(1)} %) + {taux} + {(totalTaxRate * 100).toFixed(1)} % +
Total intérêt net 0 ? 'var(--success)' : 'var(--text-muted)' }}>{net > 0 ? fmtN(net) : '—'} + {fmtN(r2( + frLignes.reduce((s, l) => s + r2(Object.values(l.mois ?? {}).reduce((ss, m) => ss + ((m.interets_bruts ?? 0) - (m.prelev_sociaux ?? 0) - (m.prelev_forfaitaire ?? 0)), 0)), 0) + + platEtr.reduce((s, p) => s + r2(Object.values(p.mois ?? {}).reduce((ss, v) => ss + v, 0)) * (1 - totalTaxRate), 0) + ))} € +
+ Plateformes françaises : prélèvements à la source (taux {(totalTaxRate * 100).toFixed(1)} %) — déclaration automatique sur 2042  ·  Plateformes étrangères : à déclarer +
+
+ )} +
+ ); + + /* ── Données 2042 — cases mixtes par code ── */ + const données2042View = (() => { + if (loading) return
Chargement…
; + + // Totaux FR + const fr2TT = frLignes.reduce((s, l) => s + (l.case_2TT ?? 0), 0); + const fr2TR = frLignes.reduce((s, l) => s + (l.case_2TR ?? 0), 0); + const fr2BH = frLignes.reduce((s, l) => s + (l.case_2BH ?? 0), 0); + const fr2CK = frLignes.reduce((s, l) => s + (l.case_2CK ?? 0), 0); + const fr2TY = frLignes.reduce((s, l) => s + (l.case_2TY ?? 0), 0); + + // Totaux étrangers + const etrBA = p => r2(Object.values(p.mois ?? {}).reduce((s, v) => s + v, 0)); + const applyFilter = items => filterMode === 'all' ? items : items.filter(i => (filterMode === 'auto' ? i.badge === BADGE_AUTO : i.badge === BADGE_DECL)); + const totalEtrBA = r2(platEtr.reduce((s, p) => s + etrBA(p), 0)); + const totalEtrIA = Math.round(totalEtrBA * rates.pfo); + + // Breakdown 2TT : FR seulement + const bd2TT = applyFilter(frLignes.filter(l => l.case_2TT > 0).map(l => ({ nom: `${l.plateforme_nom} — ${fullName(l)}`, val: l.case_2TT, badge: BADGE_AUTO }))); + const total2TT = bd2TT.reduce((s, i) => s + i.val, 0); + + // Breakdown 2TR : FR (case_2TR) + étrangères + const bd2TR = applyFilter([ + ...frLignes.filter(l => l.case_2TR > 0).map(l => ({ nom: `${l.plateforme_nom} — ${fullName(l)}`, val: l.case_2TR, badge: BADGE_AUTO })), + ...platEtr.filter(p => etrBA(p) > 0).map(p => ({ nom: p.nom, val: Math.round(etrBA(p)), badge: BADGE_DECL })), + ]); + const total2TR = bd2TR.reduce((s, i) => s + i.val, 0); + + // Breakdown 2BH : FR + étrangères + const bd2BH = applyFilter([ + ...frLignes.filter(l => l.case_2BH > 0).map(l => ({ nom: `${l.plateforme_nom} — ${fullName(l)}`, val: l.case_2BH, badge: BADGE_AUTO })), + ...platEtr.filter(p => etrBA(p) > 0).map(p => ({ nom: p.nom, val: Math.round(etrBA(p)), badge: BADGE_DECL })), + ]); + const total2BH = bd2BH.reduce((s, i) => s + i.val, 0); + + // Breakdown 2CK : FR (PFNL retenu) + étrangères (acompte 2778-SD) + const bd2CK = applyFilter([ + ...frLignes.filter(l => l.case_2CK > 0).map(l => ({ nom: `${l.plateforme_nom} — ${fullName(l)}`, val: l.case_2CK, badge: BADGE_AUTO })), + ...platEtr.filter(p => etrBA(p) > 0).map(p => ({ nom: p.nom, val: Math.round(etrBA(p) * rates.pfo), badge: BADGE_DECL })), + ]); + const total2CK = bd2CK.reduce((s, i) => s + i.val, 0); + + // Breakdown 2TY : FR seulement + const bd2TY = applyFilter(frLignes.filter(l => l.case_2TY > 0).map(l => ({ nom: `${l.plateforme_nom} — ${fullName(l)}`, val: l.case_2TY, badge: BADGE_AUTO }))); + const total2TY = bd2TY.reduce((s, i) => s + i.val, 0); + + return ( +
+
+

Report annuel — Déclaration 2042

+ Revenus {annee} à reporter sur la 2042 déposée en {Number(annee) + 1} +
+
+ {[['all','Tout'],['auto','Automatique'],['decl','À déclarer']].map(([val, label]) => ( + + ))} +
+
+
+ + + + + +
+
+ Synthèse fiscale + Les montants sont automatiquement reportés par la plateforme (IFU). + Les montants nécessitent une déclaration mensuelle 2778-SD et un report manuel sur la 2042. +
+
+ ); + })(); + + return ( +
+
+ +
+ {view === 'matrice' && matriceView} + {view === 'donnees' && données2042View} +
+
+
+ ); +} diff --git a/frontend/src/components/Cerfa2561Preview.jsx b/frontend/src/components/Cerfa2561Preview.jsx new file mode 100644 index 0000000..217d619 --- /dev/null +++ b/frontend/src/components/Cerfa2561Preview.jsx @@ -0,0 +1,791 @@ +import { useEffect, useRef, useState } from 'react'; +import { api } from '../api.js'; +import { useInvestisseur } from '../context/InvestisseurContext.jsx'; + +const fmtEUR = n => { + if (!n && n !== 0) return ''; + return new Intl.NumberFormat('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(n); +}; + +const fmtEURDec = n => { + if (!n && n !== 0) return ''; + return new Intl.NumberFormat('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n); +}; + +/* ── Cellule du formulaire ── */ +/* Layout : [code2561] [label + report 2042] [code2042 noir] [montant] */ +function Cell({ label, code2561, code2042, value, filled, noReport }) { + return ( +
+ {/* Case 1 — code 2561 (ex: KR) */} +
{code2561}
+ + {/* Case 2 — désignation + report */} +
+
{label}
+
+ {noReport ? 'Sans report sur la déclaration 2042' : 'Montant à reporter sur votre déclaration 2042'} +
+
+ + {/* Case 3 — code 2042 (ex: 2TT) blanc sur noir */} +
{code2042 ?? '—'}
+ + {/* Case 4 — montant */} +
+ {filled ? fmtEUR(value) + ' €' : '—'} +
+
+ ); +} + +function SectionTitle({ children }) { + return ( +
{children}
+ ); +} + +function FieldRow({ label, code, value }) { + return ( +
+
{code}
+
{label}
+
{value || ''}
+
+ ); +} + +/* ── Cellule éditable en place ── */ +function EditableFieldRow({ label, code, value, onSave }) { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(value ?? ''); + const inputRef = useRef(null); + + const startEdit = () => { setDraft(value ?? ''); setEditing(true); }; + const confirm = () => { onSave(draft.trim() || null); setEditing(false); }; + const onKey = e => { if (e.key === 'Enter') confirm(); if (e.key === 'Escape') setEditing(false); }; + + useEffect(() => { if (editing && inputRef.current) inputRef.current.focus(); }, [editing]); + + return ( +
+
{code}
+
{label}
+
+ {editing ? ( + <> + setDraft(e.target.value)} + onKeyDown={onKey} + style={{ + flex: 1, border: 'none', outline: 'none', background: 'transparent', + fontSize: 11, padding: '3px 8px', color: '#111', + }} + placeholder="Saisir…" + /> + + + ) : ( + + {value || 'Cliquer pour saisir'} + + )} +
+
+ ); +} + +/* ── Formulaire d'une ligne (plateforme × investisseur) ── */ +function Cerfa2561Form({ ligne, index, total }) { + const flatTax = ligne.domiciliation === 'FR' && ligne.fiscalite === 'flat_tax'; + const use2TR = ligne.type_produit_fiscal === '2TR'; + const hasPertes = ligne.case_2TY > 0; + + const [taxDetails, setTaxDetails] = useState(null); + + useEffect(() => { + api.get(`/plateforme-tax/${ligne.plateforme_id}/${ligne.annee}`) + .then(setTaxDetails) + .catch(() => setTaxDetails({ raison_sociale: ligne.plateforme_nom, siret_n: null, siret_n1: null })); + }, [ligne.plateforme_id, ligne.annee]); // eslint-disable-line + + const save = (field) => (val) => { + if (!taxDetails) return; + const updated = { ...taxDetails, [field]: val }; + setTaxDetails(updated); + api.patch(`/plateforme-tax/${taxDetails.id}`, { [field]: val }); + }; + + return ( +
+ {/* En-tête */} +
+
+
CERFA N°2561 — N°11428*27
+
Déclaration récapitulative des opérations sur valeurs mobilières et revenus de capitaux mobiliers
+
+
+
{ligne.annee}
+
Simulation — non officielle
+
+
+ +
+ {/* Avertissement */} +
+ ⚠ Ce document est une simulation générée par votre outil de suivi. Il ne remplace pas le formulaire officiel émis par la plateforme. + Vérifiez les informations auprès de {ligne.plateforme_nom} avant toute déclaration. +
+ +
+ {/* Colonne gauche */} +
+ Désignation du payeur (plateforme) + + + + + Désignation du bénéficiaire (investisseur) + + +
+ + {/* Colonne droite — infos générales */} +
+ Informations générales + + + + Récapitulatif fiscal +
+
+ Intérêts bruts{fmtEURDec(ligne.interets_bruts)} € +
+ {flatTax && <> +
+ − Prélèvements sociaux−{fmtEURDec(ligne.prelev_sociaux)} € +
+
+ − PFNL (12,8%)−{fmtEURDec(ligne.prelev_forfaitaire)} € +
+ } +
+ Intérêts nets{fmtEURDec(ligne.interets_nets)} € +
+
+ {flatTax + ? '✓ Plateforme française — PS et PFNL déjà prélevés à la source' + : '○ Plateforme étrangère — PS non prélevés, à régulariser'} +
+
+
+
+ + {/* Cases fiscales */} + Cases à remplir sur le formulaire 2561 + + {/* Section produits — 2TT ou 2TR selon paramétrage plateforme */} + {use2TR ? ( + <> +
+ PRODUITS DE PLACEMENT À REVENU FIXE +
+ 0} + /> + + ) : ( + <> +
+ PRODUITS DES MINIBONS ET DES PRÊTS DANS LE CADRE DU FINANCEMENT PARTICIPATIF +
+ 0} + /> + + )} + {use2TR ? ( + 0} + noReport + /> + ) : ( + 0} + /> + )} + + {/* Section 2BH — si PS ont été prélevés */} + {ligne.case_2BH > 0 && <> +
+ PRODUITS POUR LESQUELS LES PRÉLÈVEMENTS SOCIAUX ONT DÉJÀ ÉTÉ APPLIQUÉS +
+ 0} + /> + } + + {/* Section 2CK — flat-tax FR uniquement */} + {flatTax && ligne.case_2CK > 0 && <> +
+ CRÉDIT D'IMPÔT PRÉLÈVEMENT +
+ 0} + /> + } + + {/* Nombre d'opérations — cliquable */} + +
+
+ ); +} + +/* ── Panneau remboursements dépliable ── */ +function RembDetail({ ligne }) { + const [open, setOpen] = useState(false); + const [rows, setRows] = useState(null); + const [loading, setLoading] = useState(false); + const { activeView } = useInvestisseur(); + + const load = () => { + if (rows !== null) { setOpen(o => !o); return; } + setLoading(true); + const params = { + annee: ligne.annee, + plateforme_id: ligne.plateforme_id, + investisseur_id: ligne.investisseur_id, + ...(activeView === 'all' ? { scope: 'all' } : {}), + }; + api.get('/taxreport/cerfa2561/remboursements', params) + .then(data => { setRows(data); setOpen(true); }) + .finally(() => setLoading(false)); + }; + + const fmtDate = d => d ? d.slice(0, 10) : '—'; + + return ( + <> +
+ {loading ? 'Chargement…' : ( + <> + + + + {ligne.nb_remboursements} remboursement{ligne.nb_remboursements > 1 ? 's' : ''} pris en compte + + )} +
+ + {open && rows && ( +
+ + + + {[['Date','left'],['Projet','left'],['Capital','right'],['Intérêts bruts','right'],['Prélèv. sociaux','right'],['PFNL (2CK)','right'],['Intérêts nets','right']].map(([label, align]) => ( + + ))} + + + + {rows.map((r, i) => ( + + + + + + + + + + ))} + + + + + + + + + + +
{label}
{fmtDate(r.date_remb)}{r.nom_projet}{fmtEURDec(r.capital)} €{fmtEURDec(r.interets_bruts)} €{r.prelev_sociaux ? `−${fmtEURDec(r.prelev_sociaux)} €` : '—'}{r.prelev_forfaitaire ? `−${fmtEURDec(r.prelev_forfaitaire)} €` : '—'}{fmtEURDec(r.interets_nets)} €
Total{fmtEURDec(rows.reduce((s, r) => s + r.interets_bruts, 0))} €−{fmtEURDec(rows.reduce((s, r) => s + r.prelev_sociaux, 0))} €−{fmtEURDec(rows.reduce((s, r) => s + r.prelev_forfaitaire, 0))} €{fmtEURDec(rows.reduce((s, r) => s + r.interets_nets, 0))} €
+
+ )} + + ); +} + + +const MOIS_LABELS = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre']; +const MOIS_NUMS = ['01','02','03','04','05','06','07','08','09','10','11','12']; +const r2 = v => Math.round((v ?? 0) * 100) / 100; + +/* ── Report 2042 pour plateformes françaises ── */ +function Report2042Block2561({ lignes }) { + const frLignes = (lignes ?? []).filter(l => l.domiciliation === 'FR'); + if (frLignes.length === 0) return ( +

Aucune plateforme française avec des remboursements.

+ ); + + const total2TT = frLignes.reduce((s, l) => s + (l.case_2TT ?? 0), 0); + const total2TR = frLignes.reduce((s, l) => s + (l.case_2TR ?? 0), 0); + const total2BH = frLignes.reduce((s, l) => s + (l.case_2BH ?? 0), 0); + const total2CK = frLignes.reduce((s, l) => s + (l.case_2CK ?? 0), 0); + const total2TY = frLignes.reduce((s, l) => s + (l.case_2TY ?? 0), 0); + + const fmt = v => Number(v).toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 }); + const fullName = l => { const n = l.investisseur_nom ?? ''; const p = l.investisseur_prenom ?? ''; return p && n.startsWith(p) ? n : [p, n].filter(Boolean).join(' '); }; + + const Case2042 = ({ code, label, note, value, breakdown }) => { + if (!value) return null; + return ( +
+
+
{code}
+
+
{label}
+ {note &&
{note}
} +
+
{fmt(value)} €
+
+ {breakdown && breakdown.length > 0 && ( +
+ {breakdown.map(p => ( +
+ └ {p.nom} + {fmt(p.val)} € +
+ ))} +
+ )} +
+ ); + }; + + const annee = frLignes[0]?.annee; + + return ( +
+
+

Report annuel — Déclaration 2042

+ Revenus {annee} à reporter sur la 2042 déposée en {Number(annee) + 1} +
+ +
+ l.case_2TT > 0).map(l => ({ nom: `${l.plateforme_nom} — ${fullName(l)}`, val: l.case_2TT }))} + /> + l.case_2TR > 0).map(l => ({ nom: `${l.plateforme_nom} — ${fullName(l)}`, val: l.case_2TR }))} + /> + l.case_2BH > 0).map(l => ({ nom: `${l.plateforme_nom} — ${fullName(l)}`, val: l.case_2BH }))} + /> + l.case_2CK > 0).map(l => ({ nom: `${l.plateforme_nom} — ${fullName(l)}`, val: l.case_2CK }))} + /> + l.case_2TY > 0).map(l => ({ nom: `${l.plateforme_nom} — ${fullName(l)}`, val: l.case_2TY }))} + /> +
+ +
+ Comment fonctionne le PFU sur les plateformes françaises ? + Les plateformes françaises soumises à la Flat Tax retiennent à la source les prélèvements sociaux (17,2 %) et l'impôt forfaitaire (12,8 %). + Le montant 2CK correspond à l'acompte IR déjà versé — il s'impute sur l'impôt définitif calculé lors de votre 2042. + Le montant 2BH est déclaré en sus de 2TT/2TR pour neutraliser les prélèvements sociaux déjà prélevés. +
+
+ ); +} + +/* ── Composant principal ── */ +export default function Cerfa2561Preview({ annee, activeView, onClose, inline = false, expanded = false, onToggleExpand }) { + const { activeId } = useInvestisseur(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [filterPlat, setFilterPlat] = useState('all'); + const [view, setView] = useState('matrice'); + const contentRef = useRef(null); + + const handlePrint = () => { + const content = contentRef.current; + if (!content) return; + const platName = data?.lignes?.find(l => `${l.plateforme_id}_${l.investisseur_id}` === filterPlat)?.plateforme_nom ?? 'Plateforme'; + const printWin = window.open('', '_blank', 'width=900,height=700'); + printWin.document.write(` + + CERFA 2561 — ${annee} — ${platName} + + ${content.innerHTML}`); + printWin.document.close(); + printWin.focus(); + setTimeout(() => { printWin.print(); printWin.close(); }, 400); + }; + + useEffect(() => { + const scopeParams = activeView === 'all' ? { scope: 'all' } : {}; + api.get('/taxreport/cerfa2561', { annee, ...scopeParams }) + .then(d => { + setData(d); + const firstFr = d.lignes.find(l => l.domiciliation === 'FR') ?? d.lignes[0]; + if (firstFr) setFilterPlat(`${firstFr.plateforme_id}_${firstFr.investisseur_id}`); + }) + .finally(() => setLoading(false)); + }, [annee, activeView]); // eslint-disable-line + + const frLignes = (data?.lignes ?? []).filter(l => l.domiciliation === 'FR'); + + /* ── Tableau mensuel ── */ + const matriceView = ( +
+ {loading &&
Chargement…
} + {!loading && frLignes.length === 0 && ( +

Aucune plateforme française avec des remboursements pour {annee}.

+ )} + {!loading && frLignes.length > 0 && ( +
+
+

Suivi mensuel des intérêts bruts

+ Revenus {annee} — plateformes françaises +
+ + + + + {MOIS_LABELS.map(m => ( + + ))} + + + + + {frLignes.map((l, idx) => { + const total = r2(Object.values(l.mois ?? {}).reduce((s, m) => s + (m.interets_bruts ?? 0), 0)); + return ( + + + {MOIS_NUMS.map(m => { + const v = l.mois?.[m]?.interets_bruts ?? 0; + return ( + + ); + })} + + + ); + })} + + + {/* Total intérêt brut */} + + + {MOIS_NUMS.map(m => { + const v = r2(frLignes.reduce((s, l) => s + (l.mois?.[m]?.interets_bruts ?? 0), 0)); + return ( + + ); + })} + + + {/* Taux prélevé */} + + + {MOIS_NUMS.map(m => { + const brut = r2(frLignes.reduce((s, l) => s + (l.mois?.[m]?.interets_bruts ?? 0), 0)); + const prelev = r2(frLignes.reduce((s, l) => s + ((l.mois?.[m]?.prelev_sociaux ?? 0) + (l.mois?.[m]?.prelev_forfaitaire ?? 0)), 0)); + const taux = brut > 0 ? (prelev / brut * 100).toFixed(1) + ' %' : '—'; + return ( + + ); + })} + + + {/* Total intérêt net */} + + + {MOIS_NUMS.map(m => { + const net = r2(frLignes.reduce((s, l) => { + const mo = l.mois?.[m]; + return s + ((mo?.interets_bruts ?? 0) - (mo?.prelev_sociaux ?? 0) - (mo?.prelev_forfaitaire ?? 0)); + }, 0)); + return ( + + ); + })} + + + {/* Note de bas de tableau */} + + + + +
Plateforme — Détenteur{m}Total
+ {l.plateforme_nom} + — {(() => { const n = l.investisseur_nom ?? ''; const p = l.investisseur_prenom ?? ''; return p && n.startsWith(p) ? n : [p, n].filter(Boolean).join(' '); })()} + 0 ? 'var(--text)' : 'var(--text-muted)' }}> + {v > 0 ? v.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €' : '—'} + + {total > 0 ? total.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €' : '—'} +
Total intérêt brut 0 ? 'var(--text)' : 'var(--text-muted)' }}> + {v > 0 ? v.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '—'} + + {r2(frLignes.reduce((s, l) => s + r2(Object.values(l.mois ?? {}).reduce((ss, m) => ss + (m.interets_bruts ?? 0), 0)), 0)).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} € +
+ Taux total prélevé ({(() => { + const totalBrut = r2(frLignes.reduce((s, l) => s + r2(Object.values(l.mois ?? {}).reduce((ss, m) => ss + (m.interets_bruts ?? 0), 0)), 0)); + const totalPrelev = r2(frLignes.reduce((s, l) => s + r2(Object.values(l.mois ?? {}).reduce((ss, m) => ss + ((m.prelev_sociaux ?? 0) + (m.prelev_forfaitaire ?? 0)), 0)), 0)); + return totalBrut > 0 ? (totalPrelev / totalBrut * 100).toFixed(1) : '—'; + })()} %) + + {taux} + + {(() => { + const totalBrut = r2(frLignes.reduce((s, l) => s + r2(Object.values(l.mois ?? {}).reduce((ss, m) => ss + (m.interets_bruts ?? 0), 0)), 0)); + const totalPrelev = r2(frLignes.reduce((s, l) => s + r2(Object.values(l.mois ?? {}).reduce((ss, m) => ss + ((m.prelev_sociaux ?? 0) + (m.prelev_forfaitaire ?? 0)), 0)), 0)); + return totalBrut > 0 ? (totalPrelev / totalBrut * 100).toFixed(1) + ' %' : '—'; + })()} +
Total intérêt net 0 ? 'var(--success)' : 'var(--text-muted)' }}> + {net > 0 ? net.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '—'} + + {r2(frLignes.reduce((s, l) => s + r2(Object.values(l.mois ?? {}).reduce((ss, m) => ss + ((m.interets_bruts ?? 0) - (m.prelev_sociaux ?? 0) - (m.prelev_forfaitaire ?? 0)), 0)), 0)).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} € +
+ Taux {annee} : PFO 12,8 % + CSG 10,6 % + CRDS 0,5 % + Solidarité 7,5 % = 31,4 %  ·  Prélèvements effectués à la source par les plateformes  ·  Report automatique sur la déclaration de revenus 2042 +
+
+ )} +
+ ); + + /* ── Barre outils (vue cerfa) ── */ + const toolbar = ( +
+ {data && frLignes.length > 0 && ( + + )} +
+ {inline && onToggleExpand && ( + + )} + + {!inline && ( + + )} +
+ ); + + /* ── Vue cerfa ── */ + const cerfaView = ( +
+ {toolbar} + {loading &&
Chargement…
} + {!loading && frLignes.length === 0 && ( +
Aucune plateforme française avec des remboursements pour {annee}.
+ )} + {!loading && data?.lignes + ?.filter(l => `${l.plateforme_id}_${l.investisseur_id}` === filterPlat) + .map((ligne, i, arr) => ( + + ))} +
+ ); + + if (inline) { + return ( +
+
+ {/* Sidebar nav */} + + + {/* Contenu */} +
+ {view === 'matrice' && matriceView} + {view === 'cerfa' && cerfaView} + {view === 'report2042' && } +
+
+
+ ); + } + + /* ── Vue modale (non-inline) ── */ + return ( +
+
+ {toolbar} +
+
+ {loading &&
Chargement…
} + {!loading && data?.lignes?.length === 0 && ( +
Aucun remboursement trouvé pour {annee}.
+ )} + {!loading && data?.lignes + ?.filter(l => `${l.plateforme_id}_${l.investisseur_id}` === filterPlat) + .map((ligne, i, arr) => ( + + ))} +
+
+ ); +} diff --git a/frontend/src/components/Cerfa2778Preview.jsx b/frontend/src/components/Cerfa2778Preview.jsx new file mode 100644 index 0000000..f1fef49 --- /dev/null +++ b/frontend/src/components/Cerfa2778Preview.jsx @@ -0,0 +1,621 @@ +import { useEffect, useState } from 'react'; +import { api } from '../api.js'; + +const MOIS_LABELS = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre']; +const MOIS_NUMS = ['01','02','03','04','05','06','07','08','09','10','11','12']; + +// Taux par défaut (2026+) — remplacés par les données de /api/pfu +const DEFAULT_RATES = { pfo: 0.128, csg: 0.106, crds: 0.005, solidarite: 0.075 }; + +function getRatesForYear(annee, pfuList) { + const yr = Number(annee); + // Chercher l'année exacte, puis l'année précédente la plus proche + const sorted = [...pfuList].sort((a, b) => b.annee - a.annee); + const match = sorted.find(r => r.annee <= yr); + if (!match) return DEFAULT_RATES; + return { + pfo: (match.impot_revenu ?? 12.8) / 100, + csg: (match.csg ?? 9.2) / 100, + crds: (match.crds ?? 0.5) / 100, + solidarite: (match.solidarite ?? 7.5) / 100, + }; +} + +const r = v => Math.round((v ?? 0) * 100) / 100; +const fmtEUR = v => { + if (v == null || v === 0) return - €; + return `${Number(v).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`; +}; +const fmtInt = v => { + if (v == null || v === 0) return -; + return `${v} €`; +}; + +/* ── Simulation cases CERFA pour un montant brut ── */ +function computeCases(ba, rates) { + const R = rates ?? DEFAULT_RATES; + const BA = Math.round(ba); + const IA = Math.round(BA * R.pfo); + const PQ = Math.round(BA * R.csg); + const PV = Math.round(BA * R.crds); + const PF1 = PQ + PV; + const PG1 = Math.round(BA * R.solidarite); + const PU = PF1; + const PK = PG1; + const QR = IA + PU + PK; + const totalTax = R.pfo + R.csg + R.crds + R.solidarite; + return { BA, IA, PQ, PV, PF1, PG1, PU, PK, QR, totalTax, pfo: R.pfo, csgRate: R.csg, crdsRate: R.crds, solidRate: R.solidarite }; +} + +/* ── Composant principal ── */ +export default function Cerfa2778Preview({ annee, activeView }) { + const LS_KEY = 'cl_2778_excluded_plats'; + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [pfuList, setPfuList] = useState([]); + const [excluded, setExcluded] = useState(() => { + try { return new Set(JSON.parse(localStorage.getItem(LS_KEY)) ?? []); } + catch { return new Set(); } + }); + const [selectedMois, setSelectedMois] = useState(null); + const [view, setView] = useState('matrice'); // 'matrice' | 'cerfa' + + useEffect(() => { + setLoading(true); + setData(null); + setSelectedMois(null); + const scopeParams = activeView === 'all' ? { scope: 'all' } : {}; + Promise.all([ + api.get('/taxreport/2778', { annee, ...scopeParams }), + pfuList.length === 0 ? api.get('/pfu') : Promise.resolve(null), + ]).then(([d, pfu]) => { + setData(d); + if (pfu) setPfuList(pfu); + }).then(([d]) => { + // Auto-sélection du dernier mois avec données si vue cerfa active + if (d?.plateformes) { + const totaux = ['01','02','03','04','05','06','07','08','09','10','11','12'].map(m => { + const stored = JSON.parse(localStorage.getItem('cl_2778_excluded_plats') ?? '[]'); + const excl = new Set(stored); + return d.plateformes.filter(p => !excl.has(p.id)).reduce((s, p) => s + (p.mois[m] ?? 0), 0); + }); + const last = totaux.reduce((idx, val, i) => val > 0 ? i : idx, null); + if (last !== null) setSelectedMois(last); + } + }).finally(() => setLoading(false)); + }, [annee, activeView]); // eslint-disable-line + + if (loading || !data) return
Chargement…
; + + const plateformes = data.plateformes; + + const rates = getRatesForYear(annee, pfuList); + const TOTAL_TAX = rates.pfo + rates.csg + rates.crds + rates.solidarite; + + if (plateformes.length === 0) { + return ( +
+

+ Aucun remboursement de plateforme étrangère pour {annee}. +

+
+ ); + } + + /* ── Auto-sélection du dernier mois avec données ── */ + // (calculé après le rendu initial) + + /* ── Totaux mensuels des plateformes incluses ── */ + const totauxMois = MOIS_NUMS.map(m => { + let sum = 0; + for (const plat of plateformes) { + if (!excluded.has(plat.id)) sum += plat.mois[m] ?? 0; + } + return r(sum); + }); + + const lastMoisWithData = totauxMois.reduce((last, val, i) => val > 0 ? i : last, null); + + /* ── Données du mois sélectionné pour simulation ── */ + const moisData = selectedMois !== null ? (() => { + const mNum = MOIS_NUMS[selectedMois]; + const ba = totauxMois[selectedMois]; + const detail = plateformes + .filter(p => !excluded.has(p.id) && (p.mois[mNum] ?? 0) > 0) + .map(p => ({ nom: p.nom, montant: p.mois[mNum] })); + return { ba, detail, cases: computeCases(ba, rates) }; + })() : null; + + /* ── Toggle plateforme + persistance localStorage ── */ + const togglePlat = (id) => { + setExcluded(prev => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); else next.add(id); + localStorage.setItem(LS_KEY, JSON.stringify([...next])); + return next; + }); + }; + + const cellStyle = { + textAlign: 'right', + padding: '6px 10px', + fontSize: 'var(--fs-sm)', + whiteSpace: 'nowrap', + borderBottom: '1px solid var(--border)', + }; + const headStyle = { + textAlign: 'center', + padding: '6px 10px', + fontSize: 'var(--fs-xs)', + fontWeight: 600, + color: 'var(--text-muted)', + background: 'var(--surface-2)', + borderBottom: '1px solid var(--border)', + whiteSpace: 'nowrap', + }; + + return ( +
+ {/* ── Navigation sidebar ── */} +
+ +
+ {/* ── Vue Matrice ── */} + {view === 'matrice' && ( + <> +
+
+

Suivi mensuel des intérêts bruts

+ Revenus {annee} — plateformes étrangères +
+ + + + + {MOIS_LABELS.map((m, i) => ( + + ))} + + + + + {plateformes.map(plat => { + const isExcluded = excluded.has(plat.id); + const total = Object.values(plat.mois).reduce((a, b) => a + b, 0); + return ( + + + {MOIS_NUMS.map(m => ( + + ))} + + + ); + })} + + {/* ── Séparateur ── */} + + + {/* ── Total brut mensuel ── */} + + + {totauxMois.map((t, i) => ( + + ))} + + + + {/* ── Taux Flat Tax ── */} + + + {MOIS_NUMS.map((_, i) => ( + + ))} + + + + {/* ── Total Flat Tax ── */} + + + {totauxMois.map((t, i) => { + const { QR } = computeCases(t, rates); + return ( + + ); + })} + + + + {/* ── Intérêt net ── */} + + + {totauxMois.map((t, i) => ( + + ))} + + + +
+ Plateforme — Détenteur + {m}Total
+ + {fmtEUR(plat.mois[m] ?? null)}{fmtEUR(r(total))}
+ Total intérêt brut + {fmtEUR(t || null)} + {fmtEUR(r(totauxMois.reduce((a, b) => a + b, 0)))} +
+ Taux total prélevé ({(TOTAL_TAX * 100).toFixed(1)} %) + + {totauxMois[i] > 0 ? `${(TOTAL_TAX * 100).toFixed(2).replace('.', ',')} %` : '—'} + {`${(TOTAL_TAX * 100).toFixed(2).replace('.', ',')} %`}
+ Montant total à payer (QR) + 0 ? 'var(--danger)' : undefined }}> + {t > 0 ? fmtInt(QR) : -} + + {fmtInt(computeCases(r(totauxMois.reduce((a, b) => a + b, 0)), rates).QR)} +
+ Total intérêt net + 0 ? 'var(--success)' : undefined }}> + {fmtEUR(t > 0 ? r(t * (1 - TOTAL_TAX)) : null)} + + {fmtEUR(r(totauxMois.reduce((a, b) => a + b, 0) * (1 - TOTAL_TAX)))} +
+ +
+ Taux {annee} : PFO {(rates.pfo*100).toFixed(1)} % + CSG {(rates.csg*100).toFixed(1)} % + CRDS {(rates.crds*100).toFixed(1)} % + Solidarité {(rates.solidarite*100).toFixed(1)} % = {(TOTAL_TAX*100).toFixed(1)} % + · + ⏱ Déclaration et paiement dus dans les 15 premiers jours du mois suivant l'encaissement + · + Base imposable : intérêts bruts après déduction de l'impôt prélevé à la source à l'étranger, avant déduction retenue "directive épargne" +
+
+ + + )} + + {/* ── Vue Report 2042 ── */} + {view === 'report2042' && ( + + )} + + {/* ── Vue Données 2778-SD ── */} + {view === 'cerfa' && ( +
+ {/* Sélecteur de mois */} +
+

Mois

+
+ {MOIS_LABELS.map((label, i) => { + const ba = totauxMois[i]; + const isActive = selectedMois === i; + return ( + + ); + })} +
+ +
+ + {/* CERFA 2778-SD simulé */} + {moisData && ( +
+
+
+

+ Formulaire 2778-SD — {MOIS_LABELS[selectedMois]} {annee} +

+ Simulation indicative +
+ + {/* Détail des plateformes incluses */} + {moisData.detail.length > 1 && ( +
+
+ Plateformes incluses +
+ {moisData.detail.map(d => ( +
+ {d.nom} + {r(d.montant).toLocaleString('fr-FR', { minimumFractionDigits: 2 })} € +
+ ))} +
+ Total + {r(moisData.ba).toLocaleString('fr-FR', { minimumFractionDigits: 2 })} € +
+
+ )} + + +
+
+ )} + + {selectedMois === null && ( +
+ Sélectionnez un mois pour simuler le formulaire. +
+ )} +
+ )} +
+
+
+ ); +} + +/* ── Report annuel 2042 ── */ +function Report2042Block({ annee, totauxMois, rates, plateformes, excluded }) { + const totalBA = r(totauxMois.reduce((a, b) => a + b, 0)); + if (totalBA === 0) return null; + + const totalIA = computeCases(totalBA, rates).IA; + + // Détail par plateforme incluse (annuel) + const platDetail = plateformes + .filter(p => !excluded.has(p.id)) + .map(p => { + const ba = r(Object.values(p.mois).reduce((a, b) => a + b, 0)); + return { nom: p.nom, ba, ia: computeCases(ba, rates).IA }; + }) + .filter(p => p.ba > 0); + + const Case2042 = ({ code, label, note, value, breakdown }) => ( +
+
+
{code}
+
+
{label}
+ {note &&
{note}
} +
+
+ {Number(value).toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} € +
+
+ {breakdown && breakdown.length > 1 && ( +
+ {breakdown.map(p => ( +
+ └ {p.nom} + + {Number(p.val).toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} € + +
+ ))} +
+ )} +
+ ); + + return ( +
+
+

Report annuel — Déclaration 2042

+ Revenus {annee} à reporter sur la 2042 déposée en {Number(annee) + 1} +
+ +
+ ({ nom: p.nom, val: Math.round(p.ba) }))} + /> + ({ nom: p.nom, val: Math.round(p.ba) }))} + /> + ({ nom: p.nom, val: p.ia }))} + /> +
+ +
+ Comment fonctionne le PFO ? + Le prélèvement forfaitaire obligatoire (PFO, case 2CK) versé via la 2778-SD est un acompte sur l'impôt sur le revenu, non libératoire. + L'imposition définitive est calculée lors de votre 2042 : par défaut au taux de {(rates.pfo * 100).toFixed(1)} % (Flat Tax), + ou sur option expresse au barème progressif. L'acompte déjà versé (2CK) s'impute sur l'IR définitif — tout excédent vous est restitué. + Le montant 2BH est déclaré en sus de 2TR uniquement pour neutraliser les prélèvements sociaux déjà prélevés via la 2778-SD. +
+
+ ); +} + +/* ── Bloc cases CERFA ── */ +function CerfaBlock({ cases, ba_exact, mois, annee }) { + const { BA, IA, PQ, PV, PF1, PG1, PU, PK, QR } = cases; + + const Row = ({ label, code, value, highlight, note, caseSup, base, taux, noUnit }) => ( +
+
+ {label} + {note && {note}} +
+
{base != null ? `${base} €` : ''}
+
{taux ?? ''}
+
{code}
+
{value != null ? (noUnit ? value : `${value} €`) : ''}
+
{caseSup ?? ''}
+
+ ); + + const SectionHeader = ({ title }) => ( +
{title}
+ ); + + return ( +
+ {/* En-tête */} +
+ LibelléBase imposable (BA)TauxCaseMontant +
+ + + + + +
+ Produits de placement à revenu fixe de source étrangère soumis au prélèvement forfaitaire obligatoire non libératoire +
+ + + + +
+ Produits de placements à revenu fixe et produits afférents aux versements déductibles faisant l'objet d'un retrait en capital des PER de source étrangère +
+ + + + + + + + + +
+
+ MONTANT TOTAL À PAYER (IA + PU + PK) +
+ À reporter en première page du formulaire +
+
+
+
QR
+
{QR} €
+
+
+
+ ); +} diff --git a/frontend/src/components/CerfaRecapTable.jsx b/frontend/src/components/CerfaRecapTable.jsx new file mode 100644 index 0000000..6d27852 --- /dev/null +++ b/frontend/src/components/CerfaRecapTable.jsx @@ -0,0 +1,231 @@ +import { useEffect, useMemo, useState } from 'react'; +import { api } from '../api.js'; +import { fmtEUR } from '../utils/format.js'; + +const ICONS_BASE = '/api/icons-files/'; + +/* Colonnes du tableau — dans l'ordre de la 2561 */ +const COLS = [ + { key: 'case_2TT', code: '2TT', label: 'Produits prêts participatifs', color: '#7c3aed' }, + { key: 'case_2TR', code: '2TR', label: 'Produits placement revenu fixe', color: '#7c3aed' }, + { key: 'case_2TY', code: '2TY / AS',label: 'Pertes en capital', color: '#dc2626', danger: true }, + { key: 'case_2BH', code: '2BH', label: 'Base CSG/CRDS (PS prélevés)', color: '#1d4ed8' }, + { key: 'case_2CK', code: '2CK', label: "Crédit d'impôt prélèvement", color: '#059669' }, +]; + +export default function CerfaRecapTable({ annee, activeView }) { + const [lignes, setLignes] = useState(null); + const [loading, setLoading] = useState(true); + const [libIcons, setLibIcons] = useState({}); + const [showFr, setShowFr] = useState(true); + const [showWw, setShowWw] = useState(true); + + /* Icônes bibliothèque */ + useEffect(() => { + api.get('/icons').then(rows => { + const m = {}; + rows.forEach(r => { m[r.name] = r.filename; }); + setLibIcons(m); + }).catch(() => {}); + }, []); + + useEffect(() => { + setLoading(true); + setLignes(null); + const scopeParams = activeView === 'all' ? { scope: 'all' } : {}; + api.get('/taxreport/cerfa2561', { annee, ...scopeParams }) + .then(d => setLignes(d.lignes)) + .finally(() => setLoading(false)); + }, [annee, activeView]); // eslint-disable-line + + /* Filtrage FR / WW */ + const filtered = useMemo(() => { + if (!lignes) return []; + return lignes.filter(l => { + const isFr = l.domiciliation === 'FR'; + return isFr ? showFr : showWw; + }); + }, [lignes, showFr, showWw]); + + const totals = useMemo(() => ( + Object.fromEntries( + COLS.map(c => [c.key, filtered.reduce((s, l) => s + (l[c.key] || 0), 0)]) + ) + ), [filtered]); + + /* Composant icône bibliothèque */ + const AppIcon = ({ name, size = 66, active }) => { + const filename = libIcons[name]; + if (filename) return ( + + ); + return ( + + ); + }; + + /* Détection présence de chaque type dans les données */ + const hasFr = lignes?.some(l => l.domiciliation === 'FR') ?? false; + const hasWw = lignes?.some(l => l.domiciliation !== 'FR') ?? false; + + /* Détecter si plusieurs détenteurs distincts */ + const multiDetenteur = + new Set((lignes ?? []).map(l => l.investisseur_id).filter(v => v != null)).size > 1; + + if (loading) { + return ( +
+ Chargement… +
+ ); + } + + if (!lignes || lignes.length === 0) { + return ( +
+ Aucune donnée pour {annee}. +
+ ); + } + + return ( +
+ + {/* Header */} +
+
+
+ + + Cases 2561 + + · {annee} +
+
+ {fmtEUR(totals.case_2TT + totals.case_2TR)} +
+
+ + {/* Boutons filtre FR / WW */} +
+ {hasFr && ( + + )} + {hasWw && ( + + )} +
+
+ + {filtered.length === 0 ? ( +
+ Aucune plateforme à afficher — activez au moins un filtre. +
+ ) : ( +
+ + + + + {COLS.map(c => ( + + ))} + + + + {filtered.map(l => ( + + + {COLS.map(c => { + const v = l[c.key] || 0; + return ( + + ); + })} + + ))} + + + + + {COLS.map(c => { + const v = totals[c.key] || 0; + return ( + + ); + })} + + +
Plateforme + {c.code} + {c.label} +
+ {l.plateforme_nom} + {multiDetenteur && l.investisseur_prenom && ( + + {l.investisseur_prenom} + + )} + 0 ? { color: '#dc2626', fontWeight: 600 } : undefined} + > + {v > 0 ? fmtEUR(v) : ---} +
Total {annee} 0 ? { color: '#dc2626' } : undefined} + > + {v > 0 ? fmtEUR(v) : ---} +
+
+ )} + +

+ ⚠ Montants indicatifs. Référez-vous à votre IFU et à la notice 2041-GFI avant toute déclaration. +

+
+ ); +} diff --git a/frontend/src/components/ConfirmModal.jsx b/frontend/src/components/ConfirmModal.jsx new file mode 100644 index 0000000..87e2f20 --- /dev/null +++ b/frontend/src/components/ConfirmModal.jsx @@ -0,0 +1,37 @@ +import Modal from './Modal.jsx'; + +/** + * Modale de confirmation générique. + * Props : + * open — booléen + * title — titre de la modale (défaut : "Confirmer la suppression") + * message — texte explicatif + * confirmLabel — libellé du bouton de confirmation (défaut : "Supprimer") + * onConfirm — callback appelé au clic "Confirmer" + * onCancel — callback appelé au clic "Annuler" ou ✕ + */ +export default function ConfirmModal({ + open, + title = 'Confirmer la suppression', + message, + confirmLabel = 'Supprimer', + onConfirm, + onCancel, +}) { + return ( + + + + + } + > +

{message}

+
+ ); +} diff --git a/frontend/src/components/CountrySelect.jsx b/frontend/src/components/CountrySelect.jsx new file mode 100644 index 0000000..ea2eb90 --- /dev/null +++ b/frontend/src/components/CountrySelect.jsx @@ -0,0 +1,383 @@ +import { useState, useRef, useEffect } from 'react'; + +// Composant drapeau via flag-icons CSS (cdnjs) +// Usage : +const FlagIcon = ({ code, size = 20 }) => ( + +); + +// Liste complète ISO 3166-1 alpha-2 — noms en français +const COUNTRIES = [ + { code: 'AF', name: 'Afghanistan' }, + { code: 'ZA', name: 'Afrique du Sud' }, + { code: 'AL', name: 'Albanie' }, + { code: 'DZ', name: 'Algérie' }, + { code: 'DE', name: 'Allemagne' }, + { code: 'AD', name: 'Andorre' }, + { code: 'AO', name: 'Angola' }, + { code: 'AG', name: 'Antigua-et-Barbuda' }, + { code: 'SA', name: 'Arabie saoudite' }, + { code: 'AR', name: 'Argentine' }, + { code: 'AM', name: 'Arménie' }, + { code: 'AU', name: 'Australie' }, + { code: 'AT', name: 'Autriche' }, + { code: 'AZ', name: 'Azerbaïdjan' }, + { code: 'BS', name: 'Bahamas' }, + { code: 'BH', name: 'Bahreïn' }, + { code: 'BD', name: 'Bangladesh' }, + { code: 'BB', name: 'Barbade' }, + { code: 'BY', name: 'Biélorussie' }, + { code: 'BE', name: 'Belgique' }, + { code: 'BZ', name: 'Belize' }, + { code: 'BJ', name: 'Bénin' }, + { code: 'BT', name: 'Bhoutan' }, + { code: 'BO', name: 'Bolivie' }, + { code: 'BA', name: 'Bosnie-Herzégovine' }, + { code: 'BW', name: 'Botswana' }, + { code: 'BR', name: 'Brésil' }, + { code: 'BN', name: 'Brunéi' }, + { code: 'BG', name: 'Bulgarie' }, + { code: 'BF', name: 'Burkina Faso' }, + { code: 'BI', name: 'Burundi' }, + { code: 'CV', name: 'Cap-Vert' }, + { code: 'KH', name: 'Cambodge' }, + { code: 'CM', name: 'Cameroun' }, + { code: 'CA', name: 'Canada' }, + { code: 'CF', name: 'République centrafricaine' }, + { code: 'CL', name: 'Chili' }, + { code: 'CN', name: 'Chine' }, + { code: 'CY', name: 'Chypre' }, + { code: 'CO', name: 'Colombie' }, + { code: 'KM', name: 'Comores' }, + { code: 'CG', name: 'Congo' }, + { code: 'CD', name: 'Congo (RDC)' }, + { code: 'KP', name: 'Corée du Nord' }, + { code: 'KR', name: 'Corée du Sud' }, + { code: 'CR', name: 'Costa Rica' }, + { code: 'HR', name: 'Croatie' }, + { code: 'CU', name: 'Cuba' }, + { code: 'DK', name: 'Danemark' }, + { code: 'DJ', name: 'Djibouti' }, + { code: 'DO', name: 'République dominicaine' }, + { code: 'DM', name: 'Dominique' }, + { code: 'EG', name: 'Égypte' }, + { code: 'SV', name: 'Salvador' }, + { code: 'AE', name: 'Émirats arabes unis' }, + { code: 'EC', name: 'Équateur' }, + { code: 'ER', name: 'Érythrée' }, + { code: 'ES', name: 'Espagne' }, + { code: 'EE', name: 'Estonie' }, + { code: 'SZ', name: 'Eswatini' }, + { code: 'ET', name: 'Éthiopie' }, + { code: 'FJ', name: 'Fidji' }, + { code: 'FI', name: 'Finlande' }, + { code: 'FR', name: 'France' }, + { code: 'GA', name: 'Gabon' }, + { code: 'GM', name: 'Gambie' }, + { code: 'GE', name: 'Géorgie' }, + { code: 'GH', name: 'Ghana' }, + { code: 'GR', name: 'Grèce' }, + { code: 'GD', name: 'Grenade' }, + { code: 'GT', name: 'Guatemala' }, + { code: 'GN', name: 'Guinée' }, + { code: 'GW', name: 'Guinée-Bissau' }, + { code: 'GQ', name: 'Guinée équatoriale' }, + { code: 'GY', name: 'Guyana' }, + { code: 'HT', name: 'Haïti' }, + { code: 'HN', name: 'Honduras' }, + { code: 'HU', name: 'Hongrie' }, + { code: 'IN', name: 'Inde' }, + { code: 'ID', name: 'Indonésie' }, + { code: 'IQ', name: 'Irak' }, + { code: 'IR', name: 'Iran' }, + { code: 'IE', name: 'Irlande' }, + { code: 'IS', name: 'Islande' }, + { code: 'IL', name: 'Israël' }, + { code: 'IT', name: 'Italie' }, + { code: 'JM', name: 'Jamaïque' }, + { code: 'JP', name: 'Japon' }, + { code: 'JO', name: 'Jordanie' }, + { code: 'KZ', name: 'Kazakhstan' }, + { code: 'KE', name: 'Kenya' }, + { code: 'KG', name: 'Kirghizistan' }, + { code: 'KI', name: 'Kiribati' }, + { code: 'KW', name: 'Koweït' }, + { code: 'LA', name: 'Laos' }, + { code: 'LS', name: 'Lesotho' }, + { code: 'LV', name: 'Lettonie' }, + { code: 'LB', name: 'Liban' }, + { code: 'LR', name: 'Liberia' }, + { code: 'LY', name: 'Libye' }, + { code: 'LI', name: 'Liechtenstein' }, + { code: 'LT', name: 'Lituanie' }, + { code: 'LU', name: 'Luxembourg' }, + { code: 'MK', name: 'Macédoine du Nord' }, + { code: 'MG', name: 'Madagascar' }, + { code: 'MY', name: 'Malaisie' }, + { code: 'MW', name: 'Malawi' }, + { code: 'MV', name: 'Maldives' }, + { code: 'ML', name: 'Mali' }, + { code: 'MT', name: 'Malte' }, + { code: 'MA', name: 'Maroc' }, + { code: 'MH', name: 'Îles Marshall' }, + { code: 'MU', name: 'Maurice' }, + { code: 'MR', name: 'Mauritanie' }, + { code: 'MX', name: 'Mexique' }, + { code: 'FM', name: 'Micronésie' }, + { code: 'MD', name: 'Moldavie' }, + { code: 'MC', name: 'Monaco' }, + { code: 'MN', name: 'Mongolie' }, + { code: 'ME', name: 'Monténégro' }, + { code: 'MZ', name: 'Mozambique' }, + { code: 'MM', name: 'Myanmar' }, + { code: 'NA', name: 'Namibie' }, + { code: 'NR', name: 'Nauru' }, + { code: 'NP', name: 'Népal' }, + { code: 'NI', name: 'Nicaragua' }, + { code: 'NE', name: 'Niger' }, + { code: 'NG', name: 'Nigeria' }, + { code: 'NO', name: 'Norvège' }, + { code: 'NZ', name: 'Nouvelle-Zélande' }, + { code: 'OM', name: 'Oman' }, + { code: 'UG', name: 'Ouganda' }, + { code: 'UZ', name: 'Ouzbékistan' }, + { code: 'PK', name: 'Pakistan' }, + { code: 'PW', name: 'Palaos' }, + { code: 'PA', name: 'Panama' }, + { code: 'PG', name: 'Papouasie-Nouvelle-Guinée' }, + { code: 'PY', name: 'Paraguay' }, + { code: 'NL', name: 'Pays-Bas' }, + { code: 'PE', name: 'Pérou' }, + { code: 'PH', name: 'Philippines' }, + { code: 'PL', name: 'Pologne' }, + { code: 'PT', name: 'Portugal' }, + { code: 'QA', name: 'Qatar' }, + { code: 'RO', name: 'Roumanie' }, + { code: 'GB', name: 'Royaume-Uni' }, + { code: 'RU', name: 'Russie' }, + { code: 'RW', name: 'Rwanda' }, + { code: 'KN', name: 'Saint-Kitts-et-Nevis' }, + { code: 'SM', name: 'Saint-Marin' }, + { code: 'VC', name: 'Saint-Vincent-et-les-Grenadines' }, + { code: 'LC', name: 'Sainte-Lucie' }, + { code: 'SB', name: 'Îles Salomon' }, + { code: 'WS', name: 'Samoa' }, + { code: 'ST', name: 'Sao Tomé-et-Principe' }, + { code: 'SN', name: 'Sénégal' }, + { code: 'RS', name: 'Serbie' }, + { code: 'SC', name: 'Seychelles' }, + { code: 'SL', name: 'Sierra Leone' }, + { code: 'SG', name: 'Singapour' }, + { code: 'SK', name: 'Slovaquie' }, + { code: 'SI', name: 'Slovénie' }, + { code: 'SO', name: 'Somalie' }, + { code: 'SD', name: 'Soudan' }, + { code: 'SS', name: 'Soudan du Sud' }, + { code: 'LK', name: 'Sri Lanka' }, + { code: 'SE', name: 'Suède' }, + { code: 'CH', name: 'Suisse' }, + { code: 'SR', name: 'Suriname' }, + { code: 'SY', name: 'Syrie' }, + { code: 'TJ', name: 'Tadjikistan' }, + { code: 'TZ', name: 'Tanzanie' }, + { code: 'TD', name: 'Tchad' }, + { code: 'CZ', name: 'Tchéquie' }, + { code: 'TH', name: 'Thaïlande' }, + { code: 'TL', name: 'Timor oriental' }, + { code: 'TG', name: 'Togo' }, + { code: 'TO', name: 'Tonga' }, + { code: 'TT', name: 'Trinité-et-Tobago' }, + { code: 'TN', name: 'Tunisie' }, + { code: 'TM', name: 'Turkménistan' }, + { code: 'TR', name: 'Turquie' }, + { code: 'TV', name: 'Tuvalu' }, + { code: 'UA', name: 'Ukraine' }, + { code: 'UY', name: 'Uruguay' }, + { code: 'VU', name: 'Vanuatu' }, + { code: 'VE', name: 'Venezuela' }, + { code: 'VN', name: 'Viêt Nam' }, + { code: 'YE', name: 'Yémen' }, + { code: 'ZM', name: 'Zambie' }, + { code: 'ZW', name: 'Zimbabwe' }, + { code: 'US', name: 'États-Unis' }, +].sort((a, b) => a.name.localeCompare(b.name, 'fr')); + +export { COUNTRIES, FlagIcon }; + +/** + * CountrySelect — combobox pays avec drapeaux emoji et recherche par frappe + * + * Props : + * value : code ISO 2 lettres (ex. 'FR') + * onChange : (code) => void + * required : bool (optionnel) + */ +export default function CountrySelect({ value, onChange, required, showCode }) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(''); + const containerRef = useRef(null); + const inputRef = useRef(null); + const listRef = useRef(null); + + const selected = COUNTRIES.find(c => c.code === value); + + const filtered = search + ? COUNTRIES.filter(c => + c.name.toLowerCase().includes(search.toLowerCase()) || + c.code.toLowerCase().includes(search.toLowerCase()) + ) + : COUNTRIES; + + // Ferme le dropdown si clic en dehors + useEffect(() => { + const handler = (e) => { + if (containerRef.current && !containerRef.current.contains(e.target)) { + setOpen(false); + setSearch(''); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, []); + + const select = (code) => { + onChange(code); + setOpen(false); + setSearch(''); + }; + + const handleOpen = () => { + setOpen(true); + setSearch(''); + setTimeout(() => inputRef.current?.focus(), 0); + }; + + // Navigation clavier dans la liste + const handleKeyDown = (e) => { + if (e.key === 'Escape') { setOpen(false); setSearch(''); } + }; + + return ( +
+ {/* Trigger */} + + + {/* Dropdown */} + {open && ( +
+ {/* Champ de recherche */} +
+ setSearch(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Rechercher un pays…" + style={{ + width: '100%', + boxSizing: 'border-box', + padding: '6px 10px', + border: '1px solid var(--border)', + borderRadius: 6, + background: 'var(--surface-2, var(--surface))', + color: 'var(--text)', + fontSize: 13, + outline: 'none', + }} + /> +
+ + {/* Liste des pays */} +
+ {filtered.length === 0 && ( +
+ Aucun résultat +
+ )} + {filtered.map(c => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/DepotsMensuelTable.jsx b/frontend/src/components/DepotsMensuelTable.jsx new file mode 100644 index 0000000..3062fe8 --- /dev/null +++ b/frontend/src/components/DepotsMensuelTable.jsx @@ -0,0 +1,321 @@ +import { useEffect, useMemo, useState } from 'react'; +import { api } from '../api.js'; +import { fmtEUR } from '../utils/format.js'; + +const MOIS_LONG = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre']; +const ICONS_BASE = '/api/icons-files/'; +const COLOR_DEPOT = '#22c55e'; +const COLOR_RETRAIT = '#ef4444'; + +function hexToRgba(hex, a) { + if (!hex || hex.length < 7) return `rgba(0,0,0,${a})`; + const r = parseInt(hex.slice(1,3),16), g = parseInt(hex.slice(3,5),16), b = parseInt(hex.slice(5,7),16); + return `rgba(${r},${g},${b},${a})`; +} + +function ChevronDown({ size = 10 }) { + return ( + + + + ); +} + +export default function DepotsMensuelTable({ allRows, plats, expandButton }) { + const currentYear = new Date().getFullYear(); + const currentMonth = new Date().getMonth() + 1; + + const [annee, setAnnee] = useState(currentYear); + const [inclureDepots, setInclureDepots] = useState(true); + const [inclureRetraits, setInclureRetraits] = useState(false); + const [libIcons, setLibIcons] = useState({}); + + /* ── Toggle consolidation détenteurs ── */ + const [groupByNom, setGroupByNom] = useState(() => { + try { return localStorage.getItem('cl_tip_group_by_nom') === 'true'; } catch { return false; } + }); + const toggleGroupByNom = () => { + setGroupByNom(v => { + const next = !v; + try { localStorage.setItem('cl_tip_group_by_nom', String(next)); } catch {} + return next; + }); + }; + + /* Icones bibliotheque */ + useEffect(() => { + api.get('/icons').then(rows => { + const m = {}; + rows.forEach(r => { m[r.name] = r.filename; }); + setLibIcons(m); + }).catch(() => {}); + }, []); + + const AppIcon = ({ name, size = 28, active = false }) => { + const filename = libIcons[name]; + if (filename) return ( + + ); + return ; + }; + + /* Annees disponibles */ + const availableYears = useMemo(() => { + const set = new Set(allRows.map(r => r.date_operation?.slice(0, 4)).filter(Boolean)); + return [...set].map(Number).sort((a, b) => a - b); + }, [allRows]); + + /* Fenetre selecteur */ + const [windowStart, setWindowStart] = useState(() => { + const idx = availableYears.indexOf(currentYear); + const safe = idx >= 0 ? idx : availableYears.length - 1; + return Math.max(0, Math.min(Math.max(0, availableYears.length - 3), safe - 1)); + }); + const visibleYears = availableYears.length ? availableYears.slice(windowStart, windowStart + 3) : [annee]; + const canPrev = windowStart > 0; + const canNext = windowStart + 3 < availableYears.length; + + /* Grille par plateforme x mois */ + const { grid, stats, multiDetenteur } = useMemo(() => { + const anneeStr = String(annee); + const rows = allRows.filter(r => r.date_operation?.slice(0, 4) === anneeStr); + + const byPlat = {}; + for (const r of rows) { + const pid = r.plateforme_id; + if (!byPlat[pid]) { + byPlat[pid] = { + id: pid, + nom: r.plateforme_nom || '—', + investisseur_id: r.investisseur_id ?? null, + detenteur_nom: r.plateforme_detenteur_nom || null, + depots: Array(12).fill(0), + retraits: Array(12).fill(0), + }; + } + const mi = parseInt(r.date_operation.slice(5, 7), 10) - 1; + if (r.type === 'depot') byPlat[pid].depots[mi] += r.montant || 0; + if (r.type === 'retrait') byPlat[pid].retraits[mi] += r.montant || 0; + } + + const allPlats = Object.values(byPlat); + const multi = new Set(allPlats.map(p => p.investisseur_id).filter(v => v != null)).size > 1; + + // Consolidation par nom si demandée + let consolidated; + if (groupByNom && multi) { + const byNom = {}; + for (const p of allPlats) { + if (!byNom[p.nom]) { + byNom[p.nom] = { id: p.nom, nom: p.nom, investisseur_id: null, detenteur_nom: null, + depots: [...p.depots], retraits: [...p.retraits] }; + } else { + for (let i = 0; i < 12; i++) { + byNom[p.nom].depots[i] += p.depots[i]; + byNom[p.nom].retraits[i] += p.retraits[i]; + } + } + } + consolidated = Object.values(byNom); + } else { + consolidated = allPlats; + } + + const cellValue = (p, mi) => { + const d = inclureDepots ? p.depots[mi] : 0; + const rv = inclureRetraits ? p.retraits[mi] : 0; + if (inclureDepots && inclureRetraits) return d - rv; + return d + rv; + }; + + const grid = consolidated + .map(p => ({ ...p, months: Array.from({ length: 12 }, (_, i) => cellValue(p, i)) })) + .filter(p => p.months.some(v => v !== 0)) + .sort((a, b) => b.depots.reduce((s, v) => s + v, 0) - a.depots.reduce((s, v) => s + v, 0)); + + const monthTotals = Array.from({ length: 12 }, (_, i) => + grid.reduce((s, row) => s + row.months[i], 0)); + const grandTotal = monthTotals.reduce((s, v) => s + v, 0); + const platTotals = grid.map(row => row.months.reduce((s, v) => s + v, 0)); + const nonZero = monthTotals.filter(v => v !== 0); + const globalMoyenne = nonZero.length ? nonZero.reduce((s, v) => s + v, 0) / nonZero.length : 0; + + return { grid, stats: { monthTotals, grandTotal, platTotals, globalMoyenne }, multiDetenteur: multi }; + }, [allRows, annee, inclureDepots, inclureRetraits, groupByNom]); + + return ( +
+ +
+
+
+ {inclureDepots && ( + + + Depots + + )} + {inclureRetraits && ( + + + Retraits + + )} + {!inclureDepots && !inclureRetraits && ( + --- + )} + . {annee} +
+
{fmtEUR(stats.grandTotal)}
+
+ +
+ + + +
+ + {visibleYears.map(y => ( + + ))} + + + {expandButton} +
+
+
+ + {grid.length === 0 ? ( +
+ {!inclureDepots && !inclureRetraits + ? 'Selectionne au moins un type de mouvement.' + : 'Aucun mouvement pour ' + annee + '.'} +
+ ) : ( +
+ + + + + + + + {MOIS_LONG.map((m, i) => ( + + ))} + + + + + + {grid.map((plat, pi) => ( + + + {plat.months.map((v, mi) => ( + + ))} + + + + ))} + + + + + {stats.monthTotals.map((v, i) => ( + + ))} + + + + +
+ {annee} + +
+ + Plateforme + {multiDetenteur && ( + + )} + + + {m} + TotalMoy. mensuelle
+ {plat.nom} + {!groupByNom && multiDetenteur && plat.detenteur_nom && ( + + {plat.detenteur_nom} + + )} + + {v !== 0 ? fmtEUR(v) : ---} + + {stats.platTotals[pi] !== 0 ? fmtEUR(stats.platTotals[pi]) : ---} + + {stats.platTotals[pi] !== 0 ? fmtEUR(stats.platTotals[pi] / 12) : ---} +
Toutes les plateformes + {v !== 0 ? fmtEUR(v) : ---} + + {fmtEUR(stats.grandTotal)} + + {stats.globalMoyenne !== 0 ? fmtEUR(stats.globalMoyenne) : ---} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/DistributionChart.jsx b/frontend/src/components/DistributionChart.jsx new file mode 100644 index 0000000..1e3654d --- /dev/null +++ b/frontend/src/components/DistributionChart.jsx @@ -0,0 +1,292 @@ +import { useMemo, useState, useRef, useCallback } from 'react'; + +/* ── Palette ────────────────────────────────────────────────── */ +const PALETTE = [ + '#e8547a', '#9333ea', '#0d9488', '#f59e0b', + '#3b82f6', '#10b981', '#f97316', '#06b6d4', + '#a855f7', '#84cc16', '#ec4899', '#14b8a6', +]; + +/* ── Algorithme treemap (binary split équilibré) ────────────── */ +function buildTreemap(items, x, y, w, h) { + if (!items.length) return []; + if (items.length === 1) return [{ ...items[0], x, y, w, h }]; + + const total = items.reduce((s, i) => s + i.value, 0); + let best = 1, bestDiff = Infinity, acc = 0; + for (let i = 0; i < items.length - 1; i++) { + acc += items[i].value; + const diff = Math.abs(acc - (total - acc)); + if (diff < bestDiff) { bestDiff = diff; best = i + 1; } + } + + const g1 = items.slice(0, best); + const g2 = items.slice(best); + const r1 = g1.reduce((s, i) => s + i.value, 0) / total; + + if (w >= h) { + const w1 = w * r1; + return [ + ...buildTreemap(g1, x, y, w1, h), + ...buildTreemap(g2, x + w1, y, w - w1, h), + ]; + } else { + const h1 = h * r1; + return [ + ...buildTreemap(g1, x, y, w, h1), + ...buildTreemap(g2, x, y + h1, w, h - h1), + ]; + } +} + +/* ── Formatage : nombre entier arrondi, jamais de k€ ── */ +function fmtAmount(v) { + return Math.round(v).toLocaleString('fr-FR') + ' €'; +} + +/* ── Word-wrap SVG : découpe un nom en lignes selon la largeur dispo ── */ +function wrapText(text, maxWidth, fontSize) { + const charW = fontSize * 0.58; // largeur approx d'un caractère + const maxChars = Math.max(1, Math.floor(maxWidth / charW)); + const words = text.split(' '); + const lines = []; + let current = ''; + for (const word of words) { + const candidate = current ? current + ' ' + word : word; + if (candidate.length <= maxChars) { + current = candidate; + } else { + if (current) lines.push(current); + current = word.length > maxChars ? word.slice(0, maxChars - 1) + '…' : word; + } + } + if (current) lines.push(current); + return lines.slice(0, 3); // max 3 lignes +} + +/* ── Composant ──────────────────────────────────────────────── */ +export default function DistributionChart({ rows }) { + const [hoveredIdx, setHoveredIdx] = useState(null); + const [tooltip, setTooltip] = useState(null); // { x, y, cell } + const svgRef = useRef(null); + const wrapRef = useRef(null); + + /* Solde net par plateforme */ + const { data, allRetraits } = useMemo(() => { + if (!rows?.length) return { data: [], allRetraits: false }; + const allRetraits = rows.every(r => r.type === 'retrait'); + const byPlat = {}; + for (const r of rows) { + const key = r.plateforme_nom || `#${r.plateforme_id}`; + byPlat[key] = (byPlat[key] || 0) + r.montant; + } + const data = Object.entries(byPlat) + .filter(([, v]) => v > 0) + .sort(([, a], [, b]) => b - a) + .map(([name, value], i) => ({ name, value, color: PALETTE[i % PALETTE.length] })); + return { data, allRetraits }; + }, [rows]); + + const total = data.reduce((s, i) => s + i.value, 0); + + const W = 440, H = 290, GAP = 3; + + const cells = useMemo(() => buildTreemap(data, 0, 0, W, H), [data]); + + /* Conversion coordonnées SVG → pixels dans le wrapper */ + const handleMouseMove = useCallback((e, cell, idx) => { + if (!svgRef.current || !wrapRef.current) return; + const svgRect = svgRef.current.getBoundingClientRect(); + const wrapRect = wrapRef.current.getBoundingClientRect(); + // Position relative au wrapper (pour le tooltip absolu) + const tx = e.clientX - wrapRect.left; + const ty = e.clientY - wrapRect.top; + setHoveredIdx(idx); + setTooltip({ x: tx, y: ty, cell }); + }, []); + + const handleMouseLeave = useCallback(() => { + setHoveredIdx(null); + setTooltip(null); + }, []); + + if (!cells.length) return null; + + /* Position tooltip : évite les débordements */ + const TIP_W = 150, TIP_H = 66; + const tipStyle = tooltip ? (() => { + const ww = wrapRef.current?.offsetWidth || 400; + const wh = wrapRef.current?.offsetHeight || 360; + let tx = tooltip.x + 14; + let ty = tooltip.y - TIP_H / 2; + if (tx + TIP_W > ww - 4) tx = tooltip.x - TIP_W - 14; + if (ty < 4) ty = 4; + if (ty + TIP_H > wh - 4) ty = wh - TIP_H - 4; + return { left: tx, top: ty }; + })() : null; + + return ( +
+ + {/* ── En-tête ── */} +
+ Distribution +
+ par Plateforme + + + +
+
+ + {/* ── Treemap SVG ── */} +
+ + + + + + + + + + {/* clipPath par cellule — défini dynamiquement dans chaque */} + {cells.map((cell, i) => { + const PAD = 4; + const gx = cell.x + GAP / 2 + PAD; + const gy = cell.y + GAP / 2 + PAD; + const gw = Math.max(cell.w - GAP - PAD * 2, 0); + const gh = Math.max(cell.h - GAP - PAD * 2, 0); + return ( + + + + ); + })} + + + {cells.map((cell, i) => { + const gx = cell.x + GAP / 2; + const gy = cell.y + GAP / 2; + const gw = Math.max(cell.w - GAP, 0); + const gh = Math.max(cell.h - GAP, 0); + const cx = gx + gw / 2; + const cy = gy + gh / 2; + const pct = ((cell.value / total) * 100).toFixed(0); + const amt = (allRetraits ? '− ' : '') + fmtAmount(cell.value); + + const isHovered = hoveredIdx === i; + + /* Taille de police adaptée à la largeur disponible */ + const fsAmt = Math.min(15, Math.max(10, gw / 7.5)); + const fsName = Math.min(12, Math.max(8, gw / 9.5)); + const fsPct = Math.min(11, Math.max(7, gw / 11)); + + const lineH = 15; + + /* Seuils d'affichage */ + const canShowAmt = gw > 36 && gh > 20; + + /* Mode "paysage" : cellule plus large que haute */ + const isLandscape = gw > gh; + + /* Texte combiné "Nom — X %" : utilisé en paysage seulement s'il tient sur 1 ligne */ + const combinedText = `${cell.name} — ${pct} %`; + const combinedCharW = fsName * 0.58; + const combinedFits = isLandscape && (combinedText.length * combinedCharW) <= (gw - 10); + + /* Word-wrap du nom seul (mode portrait ou paysage sans place pour le combiné) */ + const nameLines = (gw > 40 && gh > 30) + ? wrapText(cell.name, gw - 10, fsName) + : []; + const canShowName = nameLines.length > 0; + + /* Pourcentage séparé : s'assurer que la ligne % rentre dans le clipPath (PAD=4 de chaque côté) */ + const CLIP_PAD = 4; + const textHeightSoFar = (canShowAmt ? lineH : 0) + (canShowName ? nameLines.length * lineH : 0); + const canShowPct = !combinedFits && gw > 44 && (gh - CLIP_PAD * 2) >= textHeightSoFar + lineH; + + /* Pré-calcul des positions Y */ + const textItems = []; + { + const slots = []; + if (canShowAmt) slots.push({ type: 'amt', h: lineH + 2 }); + if (combinedFits) { + slots.push({ type: 'combined', text: combinedText, h: lineH }); + } else { + if (canShowName) nameLines.forEach((l, li) => slots.push({ type: 'name', li, text: l, h: lineH })); + if (canShowPct) slots.push({ type: 'pct', h: lineH }); + } + + const totalH = slots.reduce((s, sl) => s + sl.h, 0); + let y = cy - totalH / 2 + lineH / 2; + + for (const sl of slots) { + if (sl.type === 'amt') textItems.push({ key: 'amt', text: amt, fs: fsAmt, fw: '700', op: 1, y }); + if (sl.type === 'combined') textItems.push({ key: 'combined', text: sl.text, fs: fsName, fw: '500', op: 0.92, y }); + if (sl.type === 'name') textItems.push({ key: `n${sl.li}`, text: sl.text, fs: fsName, fw: '500', op: 0.92, y }); + if (sl.type === 'pct') textItems.push({ key: 'pct', text: pct + ' %', fs: fsPct, fw: '400', op: 0.75, y }); + y += sl.h; + } + } + + return ( + handleMouseMove(e, { ...cell, pct, amt }, i)} + > + {/* Cellule colorée */} + + {/* Dégradé sombre */} + + {/* Bordure lumineuse au hover */} + {isHovered && ( + + )} + {/* Labels */} + + {textItems.map(l => ( + + {l.text} + + ))} + + + ); + })} + + + {/* ── Tooltip ── */} + {tooltip && tipStyle && ( +
+
+
+
{tooltip.cell.amt}
+
{tooltip.cell.name}
+
{tooltip.cell.pct} %
+
+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/DrillCellPanel.jsx b/frontend/src/components/DrillCellPanel.jsx new file mode 100644 index 0000000..a3266ff --- /dev/null +++ b/frontend/src/components/DrillCellPanel.jsx @@ -0,0 +1,591 @@ +import { useEffect, useMemo, useState } from 'react'; +import { api } from '../api.js'; +import { useInteretsChart } from '../context/InteretsChartContext.jsx'; +import { fmtEUR, fmtDate } from '../utils/format.js'; +import Modal from './Modal.jsx'; + +const MOIS_LONG = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre']; + +function hexToRgba(hex, a) { + if (!hex || hex.length < 7) return `rgba(79,168,232,${a})`; + const r = parseInt(hex.slice(1,3),16), g = parseInt(hex.slice(3,5),16), b = parseInt(hex.slice(5,7),16); + return `rgba(${r},${g},${b},${a})`; +} + +const round2 = v => Math.round(v * 100) / 100; + +/** + * DrillCellPanel + * + * Props : + * cell { platId, platNom, annee, mois, moisLabel } | null + * platId peut être null → toutes les plateformes + * onClose () => void (masqué si alwaysOpen=true) + * alwaysOpen bool — cache le bouton Fermer, ajuste le style + * pfuRates [] + * activeView 'single'|'all' + * activeId number|null + * plateformes [] — pour le sélecteur (optionnel) + * investissements [] — pour le calcul bulk (optionnel) + * onBulkDone () => void — callback après validation en masse + */ +export default function DrillCellPanel({ + cell, onClose, alwaysOpen = false, + pfuRates, activeView, activeId, + onEditRecu, onEditProjet, refreshKey, + investissements, plateformes, onBulkDone, +}) { + const { + inclureInterets, setInclureInterets, + inclureCapital, setInclureCapital, + inclureCashback, setInclureCashback, + netMode, + showActual, showProjected, + chartInterets, chartCapital, chartCashback, + } = useInteretsChart(); + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + /* filterPlatId : '' = toutes, sinon l'id (string) de la plateforme sélectionnée dans le dropdown */ + const [filterPlatId, setFilterPlatId] = useState(''); + const [showRecus, setShowRecus] = useState(showActual); + const [showProjetes, setShowProjetes] = useState(showProjected); + + /* ── Bulk validation ── */ + const [bulkModal, setBulkModal] = useState(false); + const [bulkItems, setBulkItems] = useState([]); + const [bulkProcessing, setBulkProcessing] = useState(false); + const [bulkProgress, setBulkProgress] = useState(0); + const [bulkDone, setBulkDone] = useState(false); + + /* ── Reset filtres quand la cellule change ── */ + useEffect(() => { + if (!cell) { setData(null); return; } + setShowRecus(showActual); + setShowProjetes(showProjected); + /* Quand une cellule spécifique est cliquée, pré-sélectionner sa plateforme */ + setFilterPlatId(cell.platId ? String(cell.platId) : ''); + }, [cell?.platId, cell?.annee, cell?.mois]); + + /* ── Fetch quand cellule OU filterPlatId change ── */ + useEffect(() => { + if (!cell) { setData(null); return; } + setLoading(true); + setData(null); + const params = { + annee: cell.annee, + mois: cell.mois, + ...(filterPlatId ? { plateforme_id: filterPlatId } : {}), + ...(activeView === 'all' ? { scope: 'all' } : {}), + }; + api.get('/dashboard/detail-cellule', params) + .then(d => setData(d)) + .catch(() => setData({ recus: [], projetes: [] })) + .finally(() => setLoading(false)); + }, [cell?.annee, cell?.mois, filterPlatId, activeView, activeId, refreshKey]); + + /* ── Taux PFU pour l'année de la cellule ── */ + const pfuReduction = useMemo(() => { + if (!pfuRates?.length || !cell) return 0; + const r = pfuRates.find(r => r.annee === cell.annee) + ?? pfuRates.reduce((best, r) => r.annee > best.annee ? r : best, pfuRates[0]); + return (r.prelev_sociaux + r.impot_revenu) / 100; + }, [pfuRates, cell]); + + /* ── Données filtrées (la plateforme est gérée côté fetch) ── */ + const recus = useMemo(() => { + if (!data) return []; + return [...data.recus].sort((a, b) => (b.date_remb || '').localeCompare(a.date_remb || '')); + }, [data]); + + const projetes = useMemo(() => { + if (!data) return []; + return [...data.projetes].sort((a, b) => (a.date_prevue || '').localeCompare(b.date_prevue || '')); + }, [data]); + + if (!cell) return null; + + /* ── Afficher la colonne Plateforme quand on est en mode "toutes" ── */ + const showPlatCol = !filterPlatId; + const multiDetenteur = plateformes && new Set(plateformes.map(p => p.investisseur_id)).size > 1; + + /* ── Calcul valeur ligne reçue ── */ + const recuRowValue = (r) => { + let v = 0; + if (inclureInterets) v += netMode ? r.interets_nets : r.interets_bruts; + if (inclureCashback) v += r.cashback ?? 0; + if (inclureCapital) v += r.capital ?? 0; + return v; + }; + + /* ── Calcul valeur ligne projetée ── */ + const projRowValue = (p) => { + let v = 0; + if (inclureInterets) v += netMode ? p.interets_prevus * (1 - pfuReduction) : p.interets_prevus; + if (inclureCapital) v += p.capital_prevu ?? 0; + return v; + }; + + /* ── Totaux ── */ + const totalRecus = recus.reduce((s, r) => s + recuRowValue(r), 0); + const totalProjetes = projetes.reduce((s, p) => s + projRowValue(p), 0); + const grandTotal = (showRecus ? totalRecus : 0) + (showProjetes ? totalProjetes : 0); + + /* ── Colonnes dynamiques ── */ + const cols = [ + inclureInterets && { key: 'interets', label: netMode ? 'Intérêts nets' : 'Intérêts bruts', color: chartInterets }, + inclureCapital && { key: 'capital', label: 'Capital', color: chartCapital }, + inclureCashback && { key: 'cashback', label: 'Cashback', color: chartCashback }, + ].filter(Boolean); + + /* Date + Plateforme(opt) + Projet + Détenteur + cols + Total */ + const colCount = 3 + (showPlatCol ? 1 : 0) + cols.length + 1; + + /* Titre du header */ + const headerTitle = filterPlatId + ? `${plateformes?.find(p => String(p.id) === filterPlatId)?.nom ?? 'Plateforme'} — ${cell.moisLabel} ${cell.annee}` + : `Toutes les plateformes — ${cell.moisLabel} ${cell.annee}`; + + /* ── Bulk : construction des payloads ── */ + const openBulkModal = () => { + if (!projetes.length) return; + const items = projetes.map(p => { + const inv = investissements?.find(i => i.id === p.investissement_id); + const plat = inv ? plateformes?.find(pl => pl.id === inv.plateforme_id) : null; + const bruts = p.interets_prevus || 0; + const year = p.date_prevue ? Number(p.date_prevue.slice(0, 4)) : cell.annee; + const rates = pfuRates?.find(r => r.annee === year) + ?? (pfuRates?.length ? pfuRates.reduce((best, r) => r.annee > best.annee ? r : best, pfuRates[0]) : null); + const methode = plat && plat.methode_remboursement !== 'choix_investisseur' + ? plat.methode_remboursement + : (inv?.methode_remboursement || 'portefeuille'); + const hasLocalTax = plat?.fiscalite === 'avec_fiscalite_locale' && plat?.taux_fiscalite_locale; + const taxe_locale = hasLocalTax ? round2(bruts * plat.taux_fiscalite_locale / 100) : 0; + const brutsApresLocal = hasLocalTax ? round2(bruts - taxe_locale) : bruts; + return { + _label: p.nom_projet, + _plat: p.plateforme_nom, + _date: p.date_prevue, + _capital: p.capital_prevu || 0, + _interets: bruts, + _total: (p.capital_prevu || 0) + bruts, + investissement_id: p.investissement_id, + date_remb: p.date_prevue, + capital: p.capital_prevu || 0, + cashback: 0, + interets_bruts_avant_local: hasLocalTax ? bruts : 0, + taxe_locale, + interets_bruts: brutsApresLocal, + prelev_sociaux: rates ? round2(brutsApresLocal * rates.prelev_sociaux / 100) : 0, + prelev_forfaitaire: rates ? round2(brutsApresLocal * rates.impot_revenu / 100) : 0, + statut: 'paye', + notes: '', + methode_remboursement: methode, + compte_id: methode === 'compte_courant' ? (inv?.compte_id || '') : '', + }; + }); + setBulkItems(items); + setBulkProgress(0); + setBulkDone(false); + setBulkProcessing(false); + setBulkModal(true); + }; + + const runBulk = async () => { + setBulkProcessing(true); + let done = 0; + for (const item of bulkItems) { + const { _label, _plat, _date, _capital, _interets, _total, ...payload } = item; + try { await api.post('/remboursements', payload); } catch (e) { /* continuer */ } + done++; + setBulkProgress(done); + } + setBulkProcessing(false); + setBulkDone(true); + if (onBulkDone) onBulkDone(); + }; + + const closeBulkModal = () => { + setBulkModal(false); + setBulkItems([]); + setBulkProgress(0); + setBulkDone(false); + setBulkProcessing(false); + }; + + /* ── Rendu ── */ + return ( +
+ + {/* ── Header ── */} +
+ {headerTitle} +
+ {[ + { active: inclureInterets, toggle: () => setInclureInterets(v => !v), label: netMode ? 'Intérêts nets' : 'Intérêts bruts' }, + { active: inclureCapital, toggle: () => setInclureCapital(v => !v), label: 'Capital' }, + { active: inclureCashback, toggle: () => setInclureCashback(v => !v), label: 'Cashback' }, + ].map(btn => ( + + ))} + {!alwaysOpen && ( + + )} +
+
+ + {/* ── Barre filtres ── */} +
+ {/* Sélecteur plateforme */} + {plateformes && plateformes.length > 0 && ( +
+ + +
+ )} + + {/* Toggle Reçus / Projetés */} +
+ {[ + { key: 'recus', label: 'Reçus', active: showRecus, toggle: () => setShowRecus(v => !v) }, + { key: 'projetes', label: 'Projetés', active: showProjetes, toggle: () => setShowProjetes(v => !v) }, + ].map(btn => ( + + ))} +
+
+ + {/* ── Corps ── */} + {loading && ( +
Chargement…
+ )} + + {!loading && data && ( +
+ + + + + {showPlatCol && } + + + {cols.map(c => ( + + ))} + + + + + {/* ── Section Reçus ── */} + {showRecus && ( + + + + + {recus.length === 0 ? ( + + ) : recus.map(r => ( + onEditRecu && onEditRecu(r)} + > + + {showPlatCol && } + + + {cols.map(c => { + let v; + if (c.key === 'interets') v = netMode ? r.interets_nets : r.interets_bruts; + else if (c.key === 'capital') v = r.capital ?? 0; + else v = r.cashback ?? 0; + return ; + })} + + + ))} + {recus.length > 0 && ( + + + {cols.map(c => { + const sum = recus.reduce((s, r) => { + if (c.key === 'interets') return s + (netMode ? r.interets_nets : r.interets_bruts); + if (c.key === 'capital') return s + (r.capital ?? 0); + return s + (r.cashback ?? 0); + }, 0); + return ; + })} + + + )} + + )} + + {/* ── Section Projetés ── */} + {showProjetes && ( + + + + + {projetes.length === 0 ? ( + + ) : projetes.map(p => ( + onEditProjet && onEditProjet(p)} + > + + {showPlatCol && } + + + {cols.map(c => { + let v; + if (c.key === 'interets') v = netMode ? p.interets_prevus * (1 - pfuReduction) : p.interets_prevus; + else if (c.key === 'capital') v = p.capital_prevu ?? 0; + else v = 0; + return ; + })} + + + ))} + {projetes.length > 0 && ( + + + {cols.map(c => { + const sum = projetes.reduce((s, p) => { + if (c.key === 'interets') return s + (netMode ? p.interets_prevus * (1 - pfuReduction) : p.interets_prevus); + if (c.key === 'capital') return s + (p.capital_prevu ?? 0); + return s; + }, 0); + return ; + })} + + + )} + + )} + + {/* ── Grand total ── */} + + + + {cols.map(c => { + const sumR = showRecus ? recus.reduce((s, r) => { + if (c.key === 'interets') return s + (netMode ? r.interets_nets : r.interets_bruts); + if (c.key === 'capital') return s + (r.capital ?? 0); + return s + (r.cashback ?? 0); + }, 0) : 0; + const sumP = showProjetes ? projetes.reduce((s, p) => { + if (c.key === 'interets') return s + (netMode ? p.interets_prevus * (1 - pfuReduction) : p.interets_prevus); + if (c.key === 'capital') return s + (p.capital_prevu ?? 0); + return s; + }, 0) : 0; + return ; + })} + + + +
DatePlateformeProjetDétenteur{c.label}Total ligne
+ Reçus ({recus.length}) +
+ Aucun remboursement reçu ce mois +
{fmtDate(r.date_remb)}{r.plateforme_nom || '—'}{r.nom_projet}{r.detenteur_nom || '—'}{fmtEUR(v)}{fmtEUR(recuRowValue(r))}
+ Sous-total reçus + {fmtEUR(sum)}{fmtEUR(totalRecus)}
+
+ Projetés ({projetes.length}) + {projetes.length > 0 && ( + + )} +
+
+ Aucune projection ce mois +
{fmtDate(p.date_prevue)}{p.plateforme_nom || '—'}{p.nom_projet}{p.detenteur_nom || '—'}{fmtEUR(v)}{fmtEUR(projRowValue(p))}
+ Sous-total projetés + {fmtEUR(sum)}{fmtEUR(totalProjetes)}
Total{fmtEUR(sumR + sumP)} + {fmtEUR(grandTotal)} +
+
+ )} + + {/* ── Modale validation en masse ── */} + {bulkModal && ( + 1 ? 's' : ''}`} + onClose={bulkProcessing ? undefined : closeBulkModal} + width={620} + footer={ + bulkDone ? ( + + ) : bulkProcessing ? ( + + Traitement en cours… {bulkProgress}/{bulkItems.length} + + ) : ( + <> + + + + ) + } + > + {bulkProcessing && ( +
+
+
0 ? (bulkProgress / bulkItems.length) * 100 : 0}%`, + transition: 'width .3s ease', + }} /> +
+
+ )} + {bulkDone && ( +
+
+ + + +
+

+ {bulkItems.length} remboursement{bulkItems.length > 1 ? 's' : ''} enregistré{bulkItems.length > 1 ? 's' : ''} +

+

+ {cell.moisLabel} {cell.annee} +

+
+ )} + {!bulkDone && ( + <> +

+ Les remboursements suivants vont être créés d'après les projections de {cell.moisLabel} {cell.annee}. + Les prélèvements fiscaux sont estimés à partir des taux PFU de l'année. +

+
+ + + + {showPlatCol && } + + + + + + + + + {bulkItems.map((item, i) => ( + + {showPlatCol && } + + + + + + + ))} + + + + + + + +
PlateformeProjetDateCapitalIntérêts brutsTotal brut
{item._plat || '—'}{item._label}{fmtDate(item._date)}{fmtEUR(item._capital)}{fmtEUR(item._interets)}{fmtEUR(item._total)}
Total{fmtEUR(bulkItems.reduce((s, i) => s + i._total, 0))}
+
+ + )} + + )} +
+ ); +} + +/* ── Styles partagés ── */ +const thStyle = { + padding: '7px 12px', + textAlign: 'left', + fontWeight: 600, + fontSize: '0.78rem', + letterSpacing: '.03em', + color: 'var(--text-muted)', + whiteSpace: 'nowrap', + borderBottom: '1px solid var(--border)', +}; +const tdStyle = { padding: '7px 12px', verticalAlign: 'middle', whiteSpace: 'nowrap' }; +const numStyle = { textAlign: 'right' }; diff --git a/frontend/src/components/InteretsChart.jsx b/frontend/src/components/InteretsChart.jsx new file mode 100644 index 0000000..799b38d --- /dev/null +++ b/frontend/src/components/InteretsChart.jsx @@ -0,0 +1,285 @@ +import { useMemo, useState, useRef } from 'react'; + +/* ── Constantes ─────────────────────────────────────────────── */ +const COLOR = '#4fa8e8'; +const BG = '#070c15'; +const GRID = 'rgba(255,255,255,0.055)'; +const LABEL = '#4a5568'; + +const RANGES = ['1J', '7J', '1M', '3M', 'YTD', '1A', 'TOUT']; +const MOIS_COURT = ['jan.','fév.','mar.','avr.','mai','juin','juil.','août','sep.','oct.','nov.','déc.']; + +/* ── Helpers ────────────────────────────────────────────────── */ +function fmtK(v) { + if (v === 0) return '0 €'; + const abs = Math.abs(v); + if (abs >= 1000) return (v / 1000).toLocaleString('fr-FR', { maximumFractionDigits: 1 }) + ' k €'; + return v.toLocaleString('fr-FR', { maximumFractionDigits: 0 }) + ' €'; +} + +function fmtAxisDate(dateStr, range) { + const d = new Date(dateStr + 'T00:00:00'); + const day = String(d.getDate()).padStart(2, '0'); + const mon = MOIS_COURT[d.getMonth()]; + const yr = d.getFullYear(); + if (range === '1J' || range === '7J') return `${day}/${String(d.getMonth()+1).padStart(2,'0')}`; + if (range === '1M' || range === '3M') return `${day} ${mon}`; + return `${day}/${String(d.getMonth()+1).padStart(2,'0')}/${String(yr).slice(2)}`; +} + +function fmtValueDisplay(v) { + return v.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €'; +} + +function fmtTodayFull() { + return new Date().toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' }); +} + +/* ── Composant ──────────────────────────────────────────────── */ +export default function InteretsChart({ rows, netMode }) { + const [range, setRange] = useState('TOUT'); + const [hover, setHover] = useState(null); + const svgRef = useRef(null); + const wrapRef = useRef(null); + + /* ── 1. Cumul complet ── */ + const allPoints = useMemo(() => { + if (!rows?.length) return []; + const byDate = {}; + for (const r of rows) { + const d = r.date_remb.slice(0, 10); + const val = netMode ? (r.interets_nets || 0) : (r.interets_bruts || 0); + byDate[d] = (byDate[d] || 0) + val; + } + let cum = 0; + return Object.keys(byDate).sort().map(date => { + cum += byDate[date]; + return { date, value: cum }; + }); + }, [rows, netMode]); + + const todayStr = new Date().toISOString().slice(0, 10); + const currentValue = allPoints.length ? allPoints[allPoints.length - 1].value : 0; + + /* ── 2. Filtrage par plage ── */ + const points = useMemo(() => { + if (!allPoints.length) return []; + if (range === 'TOUT') { + const pts = [...allPoints]; + if (pts[pts.length - 1].date < todayStr) pts.push({ date: todayStr, value: pts[pts.length - 1].value }); + return pts; + } + const now = new Date(); + let fromDate = new Date(now); + switch (range) { + case '1J': fromDate.setDate(now.getDate() - 1); break; + case '7J': fromDate.setDate(now.getDate() - 7); break; + case '1M': fromDate.setMonth(now.getMonth() - 1); break; + case '3M': fromDate.setMonth(now.getMonth() - 3); break; + case 'YTD': fromDate = new Date(now.getFullYear(), 0, 1); break; + case '1A': fromDate.setFullYear(now.getFullYear() - 1); break; + } + const fromStr = fromDate.toISOString().slice(0, 10); + const before = allPoints.filter(p => p.date < fromStr); + const startV = before.length ? before[before.length - 1].value : 0; + const after = allPoints.filter(p => p.date >= fromStr); + const pts = [{ date: fromStr, value: startV }, ...after]; + if (pts[pts.length - 1].date < todayStr) pts.push({ date: todayStr, value: pts[pts.length - 1].value }); + return pts; + }, [allPoints, range, todayStr]); + + /* ── 3. SVG dimensions ── */ + const W = 900, H = 260; + const PAD = { top: 16, right: 16, bottom: 32, left: 70 }; + const plotW = W - PAD.left - PAD.right; + const plotH = H - PAD.top - PAD.bottom; + + /* ── 4. Échelles ── */ + const { xScale, yScale, yTicks, xTicks, minDate, maxDate, yZero } = useMemo(() => { + if (points.length < 2) return {}; + const vals = points.map(p => p.value); + const dataMin = Math.min(...vals); + const dataMax = Math.max(...vals); + const lo = Math.min(0, dataMin); + const hi = Math.max(0, dataMax); + const pad = (hi - lo) * 0.1 || 1; + const scaleLo = lo - (lo < 0 ? pad : 0); + const scaleHi = hi + pad; + const valRange = scaleHi - scaleLo || 1; + + const ts = points.map(p => new Date(p.date + 'T00:00:00').getTime()); + const minDt = ts[0]; + const maxDt = ts[ts.length - 1]; + const dtRange = maxDt - minDt || 1; + + const xScale = d => PAD.left + ((new Date(d + 'T00:00:00').getTime() - minDt) / dtRange) * plotW; + const yScale = v => PAD.top + plotH - ((v - scaleLo) / valRange) * plotH; + + const step = (scaleHi - scaleLo) / 4; + const yTicks = Array.from({ length: 5 }, (_, i) => ({ v: scaleLo + step * i, y: yScale(scaleLo + step * i) })); + + const nX = Math.min(8, points.length); + const xTicks = Array.from({ length: nX }, (_, i) => { + const idx = Math.round((i / (nX - 1)) * (points.length - 1)); + return points[idx]; + }); + + return { xScale, yScale, yTicks, xTicks, minDate: minDt, maxDate: maxDt, yZero: yScale(0) }; + }, [points, plotW, plotH]); + + /* ── 5. Chemins SVG ── */ + const { linePath, areaPath } = useMemo(() => { + if (!xScale || points.length < 2) return { linePath: '', areaPath: '' }; + let line = `M ${xScale(points[0].date).toFixed(1)},${yScale(points[0].value).toFixed(1)}`; + for (let i = 1; i < points.length; i++) { + line += ` H ${xScale(points[i].date).toFixed(1)} V ${yScale(points[i].value).toFixed(1)}`; + } + const zeroY = yZero?.toFixed(1) ?? (PAD.top + plotH).toFixed(1); + const area = `${line} V ${zeroY} H ${PAD.left} Z`; + return { linePath: line, areaPath: area }; + }, [points, xScale, yScale, yZero]); + + /* ── 6. Hover ── */ + const handleMouseMove = (e) => { + if (!svgRef.current || !xScale || points.length < 2) return; + const rect = svgRef.current.getBoundingClientRect(); + const svgX = ((e.clientX - rect.left) / rect.width) * W; + const t = minDate + ((svgX - PAD.left) / plotW) * (maxDate - minDate); + let nearest = points[0], minDiff = Infinity; + for (const p of points) { + const diff = Math.abs(new Date(p.date + 'T00:00:00').getTime() - t); + if (diff < minDiff) { minDiff = diff; nearest = p; } + } + setHover({ x: xScale(nearest.date), y: yScale(nearest.value), value: nearest.value, date: nearest.date }); + }; + + const tooltipStyle = useMemo(() => { + if (!hover) return null; + const xPct = (hover.x / W) * 100; + const yPct = (hover.y / H) * 100; + const anchorRight = xPct > 65; + return { + position: 'absolute', + top: `calc(${yPct}% - 64px)`, + left: anchorRight ? 'auto' : `calc(${xPct}% + 12px)`, + right: anchorRight ? `calc(${100 - xPct}% + 12px)` : 'auto', + transform: 'none', + pointerEvents: 'none', + }; + }, [hover]); + + if (!allPoints.length) return null; + + const displayDate = hover + ? new Date(hover.date + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' }) + : fmtTodayFull(); + const displayValue = hover ? fmtValueDisplay(hover.value) : fmtValueDisplay(currentValue); + const tooltipDate = hover + ? new Date(hover.date + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' }) + : null; + + return ( +
+ + {/* ── En-tête ── */} +
+
+
{displayDate}
+
{displayValue}
+
+
+ {/* Plages temporelles */} +
+ {RANGES.map(r => ( + + ))} +
+
+
+ + {/* ── SVG ── */} + {xScale && ( + setHover(null)} + > + + + + + + + + + + + + + {/* Grille horizontale */} + {yTicks.map(({ v, y }) => ( + + + + {fmtK(v)} + + + ))} + + {/* Ligne zéro */} + {yZero && yZero > PAD.top && yZero < PAD.top + plotH && ( + + )} + + {/* Remplissage dégradé */} + + + {/* Ligne principale */} + + + {/* Labels axe X */} + {xTicks.map((p, i) => { + const anchor = i === 0 ? 'start' : i === xTicks.length - 1 ? 'end' : 'middle'; + return ( + + {fmtAxisDate(p.date, range)} + + ); + })} + + {/* Ligne verticale + point hover */} + {hover && ( + + + + + )} + + )} + + {/* ── Tooltip ── */} + {hover && tooltipStyle && ( +
+
+ {tooltipDate} + {fmtValueDisplay(hover.value)} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/InteretsDistributionChart.jsx b/frontend/src/components/InteretsDistributionChart.jsx new file mode 100644 index 0000000..48207e0 --- /dev/null +++ b/frontend/src/components/InteretsDistributionChart.jsx @@ -0,0 +1,255 @@ +import { useMemo, useState, useRef, useCallback } from 'react'; + +/* ── Palette ────────────────────────────────────────────────── */ +const PALETTE = [ + '#e8547a', '#9333ea', '#0d9488', '#f59e0b', + '#3b82f6', '#10b981', '#f97316', '#06b6d4', + '#a855f7', '#84cc16', '#ec4899', '#14b8a6', +]; + +/* ── Algorithme treemap (binary split équilibré) ─────────────── */ +function buildTreemap(items, x, y, w, h) { + if (!items.length) return []; + if (items.length === 1) return [{ ...items[0], x, y, w, h }]; + + const total = items.reduce((s, i) => s + i.value, 0); + let best = 1, bestDiff = Infinity, acc = 0; + for (let i = 0; i < items.length - 1; i++) { + acc += items[i].value; + const diff = Math.abs(acc - (total - acc)); + if (diff < bestDiff) { bestDiff = diff; best = i + 1; } + } + + const g1 = items.slice(0, best); + const g2 = items.slice(best); + const r1 = g1.reduce((s, i) => s + i.value, 0) / total; + + if (w >= h) { + const w1 = w * r1; + return [...buildTreemap(g1, x, y, w1, h), ...buildTreemap(g2, x + w1, y, w - w1, h)]; + } else { + const h1 = h * r1; + return [...buildTreemap(g1, x, y, w, h1), ...buildTreemap(g2, x, y + h1, w, h - h1)]; + } +} + +function fmtAmount(v) { + return Math.round(v).toLocaleString('fr-FR') + ' €'; +} + +function wrapText(text, maxWidth, fontSize) { + const charW = fontSize * 0.58; + const maxChars = Math.max(1, Math.floor(maxWidth / charW)); + const words = text.split(' '); + const lines = []; + let current = ''; + for (const word of words) { + const candidate = current ? current + ' ' + word : word; + if (candidate.length <= maxChars) { + current = candidate; + } else { + if (current) lines.push(current); + current = word.length > maxChars ? word.slice(0, maxChars - 1) + '…' : word; + } + } + if (current) lines.push(current); + return lines.slice(0, 3); +} + +/* ── Composant ──────────────────────────────────────────────── */ +export default function InteretsDistributionChart({ rows, netMode }) { + const [hoveredIdx, setHoveredIdx] = useState(null); + const [tooltip, setTooltip] = useState(null); + const svgRef = useRef(null); + const wrapRef = useRef(null); + + /* Intérêts cumulés par plateforme */ + const data = useMemo(() => { + if (!rows?.length) return []; + const byPlat = {}; + for (const r of rows) { + const key = r.plateforme_nom || `#${r.plateforme_id}`; + const val = netMode ? (r.interets_nets || 0) : (r.interets_bruts || 0); + byPlat[key] = (byPlat[key] || 0) + val; + } + return Object.entries(byPlat) + .filter(([, v]) => v > 0) + .sort(([, a], [, b]) => b - a) + .map(([name, value], i) => ({ name, value, color: PALETTE[i % PALETTE.length] })); + }, [rows, netMode]); + + const total = data.reduce((s, i) => s + i.value, 0); + const W = 440, H = 290, GAP = 3; + const cells = useMemo(() => buildTreemap(data, 0, 0, W, H), [data]); + + const handleMouseMove = useCallback((e, cell, idx) => { + if (!wrapRef.current) return; + const wrapRect = wrapRef.current.getBoundingClientRect(); + setHoveredIdx(idx); + setTooltip({ x: e.clientX - wrapRect.left, y: e.clientY - wrapRect.top, cell }); + }, []); + + const handleMouseLeave = useCallback(() => { + setHoveredIdx(null); + setTooltip(null); + }, []); + + if (!cells.length) return null; + + const TIP_W = 160, TIP_H = 66; + const tipStyle = tooltip ? (() => { + const ww = wrapRef.current?.offsetWidth || 440; + const wh = wrapRef.current?.offsetHeight || 360; + let tx = tooltip.x + 14; + let ty = tooltip.y - TIP_H / 2; + if (tx + TIP_W > ww - 4) tx = tooltip.x - TIP_W - 14; + if (ty < 4) ty = 4; + if (ty + TIP_H > wh - 4) ty = wh - TIP_H - 4; + return { left: tx, top: ty }; + })() : null; + + const modeLabel = netMode ? 'Nets' : 'Bruts'; + + return ( +
+ + {/* ── En-tête ── */} +
+ Intérêts {modeLabel} +
+ par Plateforme + + + +
+
+ + {/* ── Treemap SVG ── */} +
+ + + + + + + + + + {cells.map((cell, i) => { + const PAD = 4; + const gx = cell.x + GAP / 2 + PAD; + const gy = cell.y + GAP / 2 + PAD; + const gw = Math.max(cell.w - GAP - PAD * 2, 0); + const gh = Math.max(cell.h - GAP - PAD * 2, 0); + return ( + + + + ); + })} + + + {cells.map((cell, i) => { + const gx = cell.x + GAP / 2; + const gy = cell.y + GAP / 2; + const gw = Math.max(cell.w - GAP, 0); + const gh = Math.max(cell.h - GAP, 0); + const cx = gx + gw / 2; + const cy = gy + gh / 2; + const pct = ((cell.value / total) * 100).toFixed(0); + const amt = fmtAmount(cell.value); + const isHovered = hoveredIdx === i; + + const fsAmt = Math.min(15, Math.max(10, gw / 7.5)); + const fsName = Math.min(12, Math.max(8, gw / 9.5)); + const fsPct = Math.min(11, Math.max(7, gw / 11)); + const lineH = 15; + + const canShowAmt = gw > 36 && gh > 20; + const isLandscape = gw > gh; + const combinedText = `${cell.name} — ${pct} %`; + const combinedCharW = fsName * 0.58; + const combinedFits = isLandscape && (combinedText.length * combinedCharW) <= (gw - 10); + const nameLines = (gw > 40 && gh > 30) ? wrapText(cell.name, gw - 10, fsName) : []; + const canShowName = nameLines.length > 0; + const CLIP_PAD = 4; + const textHeightSoFar = (canShowAmt ? lineH : 0) + (canShowName ? nameLines.length * lineH : 0); + const canShowPct = !combinedFits && gw > 44 && (gh - CLIP_PAD * 2) >= textHeightSoFar + lineH; + + const textItems = []; + { + const slots = []; + if (canShowAmt) slots.push({ type: 'amt', h: lineH + 2 }); + if (combinedFits) { + slots.push({ type: 'combined', text: combinedText, h: lineH }); + } else { + if (canShowName) nameLines.forEach((l, li) => slots.push({ type: 'name', li, text: l, h: lineH })); + if (canShowPct) slots.push({ type: 'pct', h: lineH }); + } + const totalH = slots.reduce((s, sl) => s + sl.h, 0); + let y = cy - totalH / 2 + lineH / 2; + for (const sl of slots) { + if (sl.type === 'amt') textItems.push({ key: 'amt', text: amt, fs: fsAmt, fw: '700', op: 1, y }); + if (sl.type === 'combined') textItems.push({ key: 'combined', text: sl.text, fs: fsName, fw: '500', op: 0.92, y }); + if (sl.type === 'name') textItems.push({ key: `n${sl.li}`, text: sl.text, fs: fsName, fw: '500', op: 0.92, y }); + if (sl.type === 'pct') textItems.push({ key: 'pct', text: pct + ' %', fs: fsPct, fw: '400', op: 0.75, y }); + y += sl.h; + } + } + + return ( + handleMouseMove(e, { ...cell, pct, amt }, i)} + > + + + {isHovered && ( + + )} + + {textItems.map(l => ( + + {l.text} + + ))} + + + ); + })} + + + {/* ── Tooltip ── */} + {tooltip && tipStyle && ( +
+
+
+
{tooltip.cell.amt}
+
{tooltip.cell.name}
+
{tooltip.cell.pct} %
+
+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/InteretsDonutChart.jsx b/frontend/src/components/InteretsDonutChart.jsx new file mode 100644 index 0000000..d9755d3 --- /dev/null +++ b/frontend/src/components/InteretsDonutChart.jsx @@ -0,0 +1,456 @@ +import { useMemo, useState, useRef } from 'react'; +import { useInteretsChart } from '../context/InteretsChartContext.jsx'; + +function fmtTotal(v) { + return v.toLocaleString('fr-FR',{minimumFractionDigits:2,maximumFractionDigits:2})+' €'; +} +function fmtCenter(v) { + if (!v||v===0) return '—'; + return v.toLocaleString('fr-FR',{minimumFractionDigits:2,maximumFractionDigits:2})+' €'; +} + +/* ── Helpers SVG ──────────────────────────────────────────────── */ +function polar(cx,cy,r,deg) { + const rad=(deg-90)*Math.PI/180; + return {x:cx+r*Math.cos(rad), y:cy+r*Math.sin(rad)}; +} + +function roundedArcPath(cx, cy, outerR, innerR, startDeg, endDeg) { + const span = endDeg - startDeg; + if (span < 0.1) return ''; + const f = n => n.toFixed(2); + + if (span >= 359.9) { + const o0=polar(cx,cy,outerR,0), o1=polar(cx,cy,outerR,180); + const i0=polar(cx,cy,innerR,0), i1=polar(cx,cy,innerR,180); + return [ + `M${f(o0.x)} ${f(o0.y)} A${outerR} ${outerR} 0 1 1 ${f(o1.x)} ${f(o1.y)}`, + `A${outerR} ${outerR} 0 1 1 ${f(o0.x)} ${f(o0.y)} Z`, + `M${f(i0.x)} ${f(i0.y)} A${innerR} ${innerR} 0 1 1 ${f(i1.x)} ${f(i1.y)}`, + `A${innerR} ${innerR} 0 1 1 ${f(i0.x)} ${f(i0.y)} Z`, + ].join(' '); + } + + const capR = (outerR - innerR) / 2; + const lg = span > 180 ? 1 : 0; + const oS=polar(cx,cy,outerR,startDeg), oE=polar(cx,cy,outerR,endDeg); + const iS=polar(cx,cy,innerR,startDeg), iE=polar(cx,cy,innerR,endDeg); + + return [ + `M${f(oS.x)} ${f(oS.y)}`, + `A${outerR} ${outerR} 0 ${lg} 1 ${f(oE.x)} ${f(oE.y)}`, + `A${capR} ${capR} 0 0 1 ${f(iE.x)} ${f(iE.y)}`, + `A${innerR} ${innerR} 0 ${lg} 0 ${f(iS.x)} ${f(iS.y)}`, + `A${capR} ${capR} 0 0 0 ${f(oS.x)} ${f(oS.y)}`, + 'Z', + ].join(' '); +} + +/* ── Dimensions donut ─────────────────────────────────────────── */ +const CX=120, CY=120; +const OUTER_R=108, INNER_R=72; + +export default function InteretsDonutChart() { + const { + annee, + inclureInterets, setInclureInterets, + inclureCapital, setInclureCapital, + inclureCashback, setInclureCashback, + selectedMonth, setSelectedMonth, + showActual, showProjected, + selectActualOnly, selectProjectedOnly, setActualProjected, + months, + modeGlobal, + selectedYear, setSelectedYear, + years, + chartInterets, chartCapital, chartCashback, + netMode, + } = useInteretsChart(); + + const [hoveredArcIdx, setHoveredArcIdx] = useState(null); + const [hoveredLegIdx, setHoveredLegIdx] = useState(null); + const [centerHovered, setCenterHovered] = useState(false); + + /* ── Mémoire de filtrage ─────────────────────────────────────── */ + const prevStateRef = useRef(null); + const [hasPrev, setHasPrev] = useState(false); + + const activeCount = [inclureCapital, inclureCashback, inclureInterets].filter(Boolean).length; + + /* ── Construire un item donut ────────────────────────────────── */ + const makeItem = (label, color, opacity, actualAmt, projectedAmt) => ({ + label, color, opacity, + value: actualAmt + projectedAmt, + actualAmt, projectedAmt, + }); + + /* ── Source de données — ordre fixe : Intérêts → Capital → Cashback ── */ + const donutData = useMemo(() => { + if (activeCount===0) return []; + + if (modeGlobal) { + const src = selectedYear !== null ? years.find(y => y.y === selectedYear) : null; + const sumA = key => showActual ? (src ? (src[key]||0) : years.reduce((s,y) => s+(y[key]||0), 0)) : 0; + const sumP = key => showProjected ? (src ? (src[key]||0) : years.reduce((s,y) => s+(y[key]||0), 0)) : 0; + + if (activeCount===1) { + if (inclureInterets) { + const actual=sumA('interetsAmt'), proj=sumP('interetsProjAmt'), s=[]; + if(actual>0) s.push(makeItem('Reçu', chartInterets, 0.88, actual, 0)); + if(proj >0) s.push(makeItem('Projeté', chartInterets, 0.30, proj, 0)); + return s; + } + if (inclureCapital) { + const actual=sumA('capitalAmt'), proj=sumP('capitalProjAmt'), s=[]; + if(actual>0) s.push(makeItem('Reçu', chartCapital, 0.88, actual, 0)); + if(proj >0) s.push(makeItem('Projeté', chartCapital, 0.30, proj, 0)); + return s; + } + if (inclureCashback) { + const v=sumA('cashbackAmt'); + return v>0?[makeItem('Reçu', chartCashback, 0.88, v, 0)]:[]; + } + } + // Multi-types — ordre : Intérêts → Capital → Cashback + const s=[]; + if(inclureInterets){ + const a=sumA('interetsAmt'), p=sumP('interetsProjAmt'); + if(a+p>0) s.push(makeItem(netMode?'Intérêts nets':'Intérêts bruts', chartInterets, 0.88, a, p)); + } + if(inclureCapital){ + const a=sumA('capitalAmt'), p=sumP('capitalProjAmt'); + if(a+p>0) s.push(makeItem('Capital', chartCapital, 0.88, a, p)); + } + if(inclureCashback){ + const a=sumA('cashbackAmt'); + if(a>0) s.push(makeItem('Cashback', chartCashback, 0.88, a, 0)); + } + return s; + } + + // Mode mensuel + const src=selectedMonth!==null?months[selectedMonth]:null; + const sumA=key=>showActual ?(src?(src[key]||0):months.reduce((s,m)=>s+(m[key]||0),0)):0; + const sumP=key=>showProjected ?(src?(src[key]||0):months.reduce((s,m)=>s+(m[key]||0),0)):0; + + if (activeCount===1) { + if (inclureInterets) { + const actual=sumA('interetsAmt'), proj=sumP('interetsProjAmt'), s=[]; + if(actual>0) s.push(makeItem('Reçu', chartInterets, 0.88, actual, 0)); + if(proj >0) s.push(makeItem('Projeté', chartInterets, 0.30, proj, 0)); + return s; + } + if (inclureCapital) { + const actual=sumA('capitalAmt'), proj=sumP('capitalProjAmt'), s=[]; + if(actual>0) s.push(makeItem('Reçu', chartCapital, 0.88, actual, 0)); + if(proj >0) s.push(makeItem('Projeté', chartCapital, 0.30, proj, 0)); + return s; + } + if (inclureCashback) { + const v=sumA('cashbackAmt'); + return v>0?[makeItem('Reçu', chartCashback, 0.88, v, 0)]:[]; + } + } + // Multi-types — ordre : Intérêts → Capital → Cashback + const s=[]; + if(inclureInterets){ + const a=sumA('interetsAmt'), p=sumP('interetsProjAmt'); + if(a+p>0) s.push(makeItem(netMode?'Intérêts nets':'Intérêts bruts', chartInterets, 0.88, a, p)); + } + if(inclureCapital){ + const a=sumA('capitalAmt'), p=sumP('capitalProjAmt'); + if(a+p>0) s.push(makeItem('Capital', chartCapital, 0.88, a, p)); + } + if(inclureCashback){ + const a=sumA('cashbackAmt'); + if(a>0) s.push(makeItem('Cashback', chartCashback, 0.88, a, 0)); + } + return s; + },[months,selectedMonth,inclureCapital,inclureCashback,inclureInterets, + showActual,showProjected,chartCapital,chartCashback,chartInterets, + modeGlobal,years,selectedYear,activeCount]); + + const total=donutData.reduce((s,d)=>s+d.value,0); + const GAP = donutData.length <= 1 ? 0 : 4; + + const arcs = useMemo(() => { + const out=[]; let cur=0; + donutData.forEach(d => { + const sweep = total > 0 ? (d.value / total) * 360 : 0; + const s = cur + GAP/2, e = cur + sweep - GAP/2; + if (e - s > 0.1) out.push({...d, startDeg:s, endDeg:e}); + cur += sweep; + }); + return out; + },[donutData,total,GAP]); + + /* ── Clic sur un quartier ou une ligne de légende ───────────── */ + const handleArcClick = (item) => { + prevStateRef.current = { + inclureInterets, inclureCapital, inclureCashback, + showActual, showProjected, + }; + setHasPrev(true); + + if (activeCount > 1) { + setInclureCapital(item.label === 'Capital'); + setInclureCashback(item.label === 'Cashback'); + setInclureInterets(item.label === 'Intérêts nets' || item.label === 'Intérêts bruts'); + } else { + if (item.label === 'Reçu') selectActualOnly(); + if (item.label === 'Projeté') selectProjectedOnly(); + } + }; + + /* ── Retour arrière via clic centre ──────────────────────────── */ + const handleCenterClick = () => { + if (!hasPrev || !prevStateRef.current) return; + const ps = prevStateRef.current; + setInclureCapital(ps.inclureCapital); + setInclureCashback(ps.inclureCashback); + setInclureInterets(ps.inclureInterets); + setActualProjected(ps.showActual, ps.showProjected); + prevStateRef.current = null; + setHasPrev(false); + }; + + /* ── Tooltip positionné sur le point extérieur de l'arc ────── */ + const tooltipArc = hoveredArcIdx !== null ? arcs[hoveredArcIdx] : null; + const tooltipPos = tooltipArc ? (() => { + const midAngle = (tooltipArc.startDeg + tooltipArc.endDeg) / 2; + const tipPt = polar(CX, CY, OUTER_R + 12, midAngle); + return { + pctX: (tipPt.x / 240) * 100, + pctY: (tipPt.y / 240) * 100, + onRight: tipPt.x >= CX, + onBottom: tipPt.y >= CY, + }; + })() : null; + + /* ── Sous-détail reçu/projeté pour la légende ───────────────── */ + const getLegendDetail = (d) => { + if (activeCount <= 1) return null; + if (d.actualAmt > 0 && d.projectedAmt > 0) { + if (d.label !== 'Intérêts nets' && d.label !== 'Intérêts bruts' && d.label !== 'Capital') return null; + return `dont ${fmtTotal(d.actualAmt)} reçus · ${fmtTotal(d.projectedAmt)} projetés`; + } + return null; + }; + + /* ── Labels / sélection ──────────────────────────────────────── */ + const centerLabel = modeGlobal + ? (selectedYear !== null ? String(selectedYear) : 'Total') + : (selectedMonth !== null ? months[selectedMonth].label : 'Total'); + + const headerSub = modeGlobal + ? (selectedYear !== null ? String(selectedYear) : 'Toutes les années') + : (selectedMonth !== null ? `${months[selectedMonth].labelLong} ${annee}` : String(annee)); + + const hasSelection = modeGlobal ? selectedYear !== null : selectedMonth !== null; + + const clearSelection = () => { + if (modeGlobal) setSelectedYear(null); + else setSelectedMonth(null); + }; + + return ( +
+ + {/* ── SVG donut ── */} +
+
{ setHoveredArcIdx(null); setCenterHovered(false); }} + > + + {/* Anneau de fond */} + + + {/* Segments */} + {total>0 && arcs.map((arc,i)=>( + =359.9?'evenodd':undefined} + opacity={hoveredArcIdx===i + ? Math.min(1,(arc.opacity??0.88)+0.12) + : (arc.opacity??0.88)} + style={{cursor:'pointer',transition:'opacity .12s'}} + onMouseEnter={()=>setHoveredArcIdx(i)} + onClick={()=>handleArcClick(arc)} + /> + ))} + + {/* Zone cliquable centre */} + setCenterHovered(true)} + onMouseLeave={()=>setCenterHovered(false)} + onClick={handleCenterClick} + /> + + {/* Indicateur retour arrière */} + {hasPrev && ( + + ↩ + + )} + + {/* Texte central */} + + {centerLabel} + + + {fmtCenter(total)} + + + + {/* ── Tooltip arc ── */} + {tooltipArc && tooltipPos && ( +
+
+ + + {tooltipArc.label} + + + {fmtTotal(tooltipArc.value)} + + + {total>0 ? ((tooltipArc.value/total)*100).toFixed(1)+' %' : '—'} + + {(activeCount > 1 || tooltipArc.label === 'Reçu' || tooltipArc.label === 'Projeté') && ( + + Cliquer pour {activeCount > 1 ? 'isoler' : 'sélectionner'} + + )} +
+
+ )} + + {/* ── Tooltip centre ── */} + {centerHovered && hasPrev && ( +
+
+ + ↩ Restaurer le filtrage précédent + +
+
+ )} +
+
+ + {/* ── Légende ── */} +
+
+ Répartition + clearSelection():undefined} + title={hasSelection?'Revenir à la vue globale':undefined} + > + {headerSub} + {hasSelection&&×} + +
+ + {donutData.length===0 && ( +
+ Aucun type sélectionné +
+ )} + + {donutData.map((d,i)=>{ + const detail = getLegendDetail(d); + const isClickable = activeCount > 1 || d.label === 'Reçu' || d.label === 'Projeté'; + const isHov = hoveredLegIdx === i; + return ( +
handleArcClick(d) : undefined} + onMouseEnter={isClickable ? ()=>setHoveredLegIdx(i) : undefined} + onMouseLeave={isClickable ? ()=>setHoveredLegIdx(null) : undefined} + style={{ + display:'flex', alignItems:'center', gap:8, + padding:'7px 6px', + marginLeft:-6, marginRight:-6, + borderTop: i>0 ? '1px solid var(--border)' : 'none', + cursor: isClickable ? 'pointer' : 'default', + borderRadius:6, + background: isHov ? 'var(--surface-2)' : 'transparent', + transition:'background .12s', + }} + > + +
+ + {d.label} + + + {fmtTotal(d.value)} + + {detail && ( + + {detail} + + )} +
+ + {total>0?((d.value/total)*100).toFixed(1)+' %':'—'} + +
+ ); + })} + + +
+ +
+ ); +} diff --git a/frontend/src/components/InteretsMensuelsChart.jsx b/frontend/src/components/InteretsMensuelsChart.jsx new file mode 100644 index 0000000..87227db --- /dev/null +++ b/frontend/src/components/InteretsMensuelsChart.jsx @@ -0,0 +1,542 @@ +import { useState, useRef, useEffect } from 'react'; +import { api } from '../api.js'; +import { useInteretsChart } from '../context/InteretsChartContext.jsx'; + +const ICONS_BASE = '/api/icons-files/'; + +const GRID = 'rgba(255,255,255,0.055)'; +const LABEL = '#4a5568'; +const MOIS = ['Jan','Fév','Mar','Avr','Mai','Jun','Jul','Aoû','Sep','Oct','Nov','Déc']; + +function fmtShort(v) { + if (!v || v === 0) return ''; + const abs = Math.abs(v); + if (abs >= 1000) return (v/1000).toLocaleString('fr-FR',{maximumFractionDigits:1})+'k €'; + return v.toLocaleString('fr-FR',{minimumFractionDigits:1,maximumFractionDigits:1})+' €'; +} +function fmtTotal(v) { + return v.toLocaleString('fr-FR',{minimumFractionDigits:2,maximumFractionDigits:2})+' €'; +} +function hexToRgba(hex, a) { + if (!hex||hex.length<7) return `rgba(79,168,232,${a})`; + const r=parseInt(hex.slice(1,3),16), g=parseInt(hex.slice(3,5),16), b=parseInt(hex.slice(5,7),16); + return `rgba(${r},${g},${b},${a})`; +} + +export default function InteretsMensuelsChart() { + const { + annee, setAnnee, availableYears, + inclureInterets, setInclureInterets, + inclureCapital, setInclureCapital, + inclureCashback, setInclureCashback, + selectedMonth, setSelectedMonth, + showActual, toggleActual, + showProjected, toggleProjected, + months, annualTotal, + // Mode global (TOUT) + modeGlobal, toggleModeGlobal, + selectedYear, setSelectedYear, + years, globalTotal, + netMode, + chartInterets, chartCapital, chartCashback, + } = useInteretsChart(); + + // État local uniquement + const [hovered, setHovered] = useState(null); + const [windowStart, setWindowStart] = useState(0); + const [libIcons, setLibIcons] = useState({}); + const [isMobile, setIsMobile] = useState(() => typeof window !== 'undefined' && window.innerWidth < 768); + const [sheetOpen, setSheetOpen] = useState(false); + const initializedRef = useRef(false); + + useEffect(() => { + api.get('/icons').then(rows => { + const m = {}; + rows.forEach(r => { m[r.name] = r.filename; }); + setLibIcons(m); + }).catch(() => {}); + }, []); + + useEffect(() => { + const onResize = () => setIsMobile(window.innerWidth < 768); + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, []); + + // Réinitialiser le hovered au changement de mode + useEffect(() => { setHovered(null); }, [modeGlobal]); + + const visibleYears = availableYears.length ? availableYears.slice(windowStart, windowStart+3) : [annee]; + const canPrev = windowStart > 0; + const canNext = windowStart + 3 < availableYears.length; + + useEffect(() => { + if (!availableYears.length || initializedRef.current) return; + initializedRef.current = true; + const idx = availableYears.indexOf(annee); + const safe = idx >= 0 ? idx : availableYears.length - 1; + setWindowStart(Math.max(0, Math.min(availableYears.length - 3, safe - 1))); + }, [availableYears]); + + /* ── Données affichées selon le mode ── */ + const items = modeGlobal ? years : months; + const barCount = items.length; + + /* ── SVG layout ── */ + const W = 900, H = 280; + const PAD = { top: 52, right: 20, bottom: 34, left: 70 }; + const plotW = W - PAD.left - PAD.right; + const plotH = H - PAD.top - PAD.bottom; + const gap = modeGlobal ? 10 : 8; + const barW = barCount > 0 ? Math.floor((plotW - gap * (barCount - 1)) / barCount) : 60; + const barBotY = PAD.top + plotH; + + const filteredTotal = item => + (showActual ? item.capitalAmt + item.cashbackAmt + item.interetsAmt : 0) + + (showProjected ? item.capitalProjAmt + item.interetsProjAmt : 0); + + const filteredSum = items.reduce((s, item) => s + filteredTotal(item), 0); + const rawMax = Math.max(...items.map(item => filteredTotal(item)), 0.01); + const niceStep = (() => { + const raw = rawMax/4, mag = Math.pow(10,Math.floor(Math.log10(raw))), n = raw/mag; + return (n<1.5?1:n<3.5?2:n<7.5?5:10)*mag; + })(); + const niceMax = Math.ceil(rawMax/niceStep)*niceStep || niceStep; + const yScale = v => PAD.top + plotH - (v/niceMax)*plotH; + const barX = i => PAD.left + i*(barW+gap); + const yTicks = Array.from({length: Math.round(niceMax/niceStep)+1}, (_,i) => ({v:i*niceStep, y:yScale(i*niceStep)})); + + /* ── Tooltip ── */ + const tooltipItem = hovered !== null ? items[hovered] : null; + const tooltipBCX = hovered !== null ? barX(hovered) + barW/2 : 0; + const tooltipBTY = hovered !== null ? yScale(filteredTotal(items[hovered])) : 0; + const anchorRight = tooltipBCX / W > 0.65; + + const buildSegments = (item) => { + const actual = []; + if (showActual) { + if (inclureCapital && item.capitalAmt > 0) actual.push({color:chartCapital, v:item.capitalAmt}); + if (inclureCashback && item.cashbackAmt > 0) actual.push({color:chartCashback, v:item.cashbackAmt}); + if (inclureInterets && item.interetsAmt > 0) actual.push({color:chartInterets, v:item.interetsAmt}); + } + const projected = []; + if (showProjected) { + if (inclureCapital && item.capitalProjAmt > 0) projected.push({color:chartCapital, v:item.capitalProjAmt}); + if (inclureInterets && item.interetsProjAmt > 0) projected.push({color:chartInterets, v:item.interetsProjAmt}); + } + return { actual, projected }; + }; + + const activeTypes = [ + inclureCapital && {key:'capital', color:chartCapital, label:'Capital'}, + inclureCashback && {key:'cashback', color:chartCashback, label:'Cashback'}, + inclureInterets && {key:'interets', color:chartInterets, label:netMode?'Intérêts nets':'Intérêts bruts'}, + ].filter(Boolean); + + const activeTypeCount = [inclureInterets, inclureCapital, inclureCashback].filter(Boolean).length; + + const AppIcon = ({ name, size = 28, active = false }) => { + const filename = libIcons[name]; + if (filename) return ( + + ); + return ; + }; + + /* ── Label en-tête ── */ + + + return ( +
+ + {/* ── En-tête ── */} +
+
+
+ {inclureInterets && ( + + + {netMode?'Intérêts nets':'Intérêts bruts'} + + )} + {inclureCapital && ( + + + Capital + + )} + {inclureCashback && ( + + + Cashback + + )} + {!inclureInterets && !inclureCapital && !inclureCashback && ( + + )} + · {modeGlobal?'Toutes les années':annee} +
+
{fmtTotal(filteredSum)}
+
+ + {isMobile ? ( + /* ── Mobile : bouton Filtres ── */ + + ) : ( + /* ── Desktop : controls existants ── */ +
+ + + +
+ {!modeGlobal && <> + + {visibleYears.map(y=>( + + ))} + + } + +
+
+ )} +
+ + {/* ── SVG bar chart ── */} +
+ setHovered(null)} + > + {yTicks.map(({v,y},i)=>( + + + {v===0?'':fmtShort(v)} + + ))} + + {items.map((item,i)=>{ + const x=barX(i), isHov=hovered===i; + // Sélection : mois en mode mensuel, année en mode global + const isSel = modeGlobal ? (selectedYear===item.y) : (selectedMonth===i); + const dimmed = modeGlobal + ? (selectedYear!==null && !isSel) + : (selectedMonth!==null && !isSel); + const {actual:aSegs,projected:pSegs}=buildSegments(item); + let cumH=0; + const aRects=aSegs.map(s=>{const h=(s.v/niceMax)*plotH; const r={color:s.color,x,y:barBotY-cumH-h,w:barW,h,isP:false}; cumH+=h; return r;}); + const pRects=pSegs.map(s=>{const h=(s.v/niceMax)*plotH; const r={color:s.color,x,y:barBotY-cumH-h,w:barW,h,isP:true}; cumH+=h; return r;}); + const all=[...aRects,...pRects]; + const totalH=(filteredTotal(item)/niceMax)*plotH; + const topColor=all.length>0?all[all.length-1].color:LABEL; + const isCurrentMark = modeGlobal ? item.isCurrent : item.isCurrentMonth; + return ( + setHovered(i)} + onClick={()=>{ + if (modeGlobal) { + setSelectedYear(prev => prev===item.y ? null : item.y); + } else { + setSelectedMonth(prev => prev===i ? null : i); + } + }} + style={{cursor:'pointer', opacity:dimmed?0.35:1, transition:'opacity .15s'}} + > + {all.map((r,ri)=>( + + ))} + {filteredTotal(item)>0&&( + + {fmtShort(filteredTotal(item))} + + )} + + {item.label} + + {isSel&&} + + ); + })} + + + {/* Tooltip */} + {tooltipItem&&tooltipItem.total>0&&( +
+
+ + {modeGlobal + ? `${tooltipItem.label}${tooltipItem.isCurrent?' · année en cours':''}` + : `${MOIS[tooltipItem.m-1]} ${annee}${tooltipItem.isCurrentMonth?' · mois en cours':''}` + } + + {(()=>{ + const ROW=({label,value,color=null,indent=false,muted=false})=>( + + {color&&} + {label} + {value} + + ); + const hasMix=tooltipItem.actual>0&&tooltipItem.projected>0; + const multi=[inclureCapital,inclureCashback,inclureInterets].filter(Boolean).length>1; + return(<> + {tooltipItem.actual>0&&<> + + {multi&&inclureCapital &&tooltipItem.capitalAmt >0&&} + {multi&&inclureCashback&&tooltipItem.cashbackAmt>0&&} + {multi&&inclureInterets&&tooltipItem.interetsAmt>0&&} + {!multi&&inclureInterets&&} + {!multi&&inclureCapital &&} + {!multi&&inclureCashback&&} + } + {tooltipItem.projected>0&&<> + + {multi&&inclureCapital &&tooltipItem.capitalProjAmt >0&&} + {multi&&inclureInterets&&tooltipItem.interetsProjAmt>0&&} + {!multi&&inclureInterets&&} + {!multi&&inclureCapital&&tooltipItem.capitalProjAmt>0&&} + } + {hasMix&&Total{fmtTotal(tooltipItem.total)}} + {!hasMix&&Total{fmtTotal(tooltipItem.total)}} + ); + })()} +
+
+ )} +
+ + {/* ── Légende + sélecteur Reçu/Projeté ── */} +
+ + {/* Sélecteur Reçu/Projeté — desktop uniquement (mobile → bottom sheet) */} + {!isMobile && ( +
+ {[ + {key:'actual', label:'Reçu', active:showActual, toggle:toggleActual}, + {key:'projected', label:'Projeté', active:showProjected, toggle:toggleProjected}, + ].map(btn=>( + + ))} +
+ )} + + {/* Légende couleurs */} +
+ {activeTypes.map(t=>( +
+ {showActual&&( + + + {t.label}{t.key==='interets'?' reçus':' reçu'} + + )} + {showProjected&&( + + + {t.label}{t.key==='interets'?' projetés':' projeté'} + + )} +
+ ))} +
+
+ + {/* ── Bottom sheet mobile ── */} + {isMobile && ( + <> + {/* Overlay */} +
setSheetOpen(false)} + style={{ + position:'fixed', inset:0, zIndex:200, + background:'rgba(0,0,0,0.45)', + opacity: sheetOpen ? 1 : 0, + pointerEvents: sheetOpen ? 'auto' : 'none', + transition:'opacity .25s', + }} + /> + {/* Sheet */} +
+ {/* Handle */} +
+ + {/* Types */} +
+ Types +
+ {[ + {key:'interets', label:netMode?'Intérêts nets':'Intérêts bruts', color:chartInterets, active:inclureInterets, set:setInclureInterets}, + {key:'capital', label:'Capital', color:chartCapital, active:inclureCapital, set:setInclureCapital}, + {key:'cashback', label:'Cashback', color:chartCashback, active:inclureCashback, set:setInclureCashback}, + ].map((t,i,arr)=>( +
t.set(v=>!v)} style={{ + display:'flex', alignItems:'center', justifyContent:'space-between', + padding:'12px 0', + borderBottom: i +
+ + {t.label} +
+ {/* Toggle */} +
+
+
+
+ ))} + + {/* Période */} +
+ Période +
+
+ {availableYears.map(y=>( + + ))} + +
+ + {/* Affichage */} +
+ Affichage +
+ {[ + {key:'actual', label:'Reçu', active:showActual, toggle:toggleActual}, + {key:'projected', label:'Projeté', active:showProjected, toggle:toggleProjected}, + ].map((btn,i,arr)=>( +
btn.toggle()} style={{ + display:'flex', alignItems:'center', justifyContent:'space-between', + padding:'12px 0', + borderBottom: i + {btn.label} +
+
+
+
+ ))} +
+ + )} + +
+ ); +} diff --git a/frontend/src/components/InvChart.jsx b/frontend/src/components/InvChart.jsx new file mode 100644 index 0000000..e6ec861 --- /dev/null +++ b/frontend/src/components/InvChart.jsx @@ -0,0 +1,312 @@ +import { useMemo, useState, useRef } from 'react'; + +/* ── Constantes ─────────────────────────────────────────────── */ +const BLUE = '#4fa8e8'; +const BG = '#070c15'; +const GRID = 'rgba(255,255,255,0.055)'; +const LABEL = '#4a5568'; + +const RANGES = ['1J', '7J', '1M', '3M', 'YTD', '1A', 'TOUT']; +const MOIS_COURT = ['jan.','fév.','mar.','avr.','mai','juin','juil.','août','sep.','oct.','nov.','déc.']; + +const W = 900, H = 260; +const PAD = { top: 16, right: 16, bottom: 32, left: 70 }; +const plotW = W - PAD.left - PAD.right; +const plotH = H - PAD.top - PAD.bottom; + +/* ── Helpers ────────────────────────────────────────────────── */ +function fmtK(v) { + if (v === 0) return '0 €'; + const abs = Math.abs(v); + if (abs >= 1000) return (v / 1000).toLocaleString('fr-FR', { maximumFractionDigits: 0 }) + ' k €'; + return v.toLocaleString('fr-FR', { maximumFractionDigits: 0 }) + ' €'; +} + +function fmtAxisDate(dateStr, range) { + const d = new Date(dateStr + 'T00:00:00'); + const day = String(d.getDate()).padStart(2, '0'); + const mon = MOIS_COURT[d.getMonth()]; + const yr = d.getFullYear(); + if (range === '1J' || range === '7J') return `${day}/${String(d.getMonth()+1).padStart(2,'0')}`; + if (range === '1M' || range === '3M') return `${day} ${mon}`; + return `${day}/${String(d.getMonth()+1).padStart(2,'0')}/${String(yr).slice(2)}`; +} + +function fmtValueDisplay(v) { + return v.toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 }) + ' €'; +} + +/* ── Composant ──────────────────────────────────────────────── */ +export default function InvChart({ rows, remboursements, reinvestissements, platYear }) { + const [range, setRange] = useState('TOUT'); + const [hover, setHover] = useState(null); + const svgRef = useRef(null); + const wrapRef = useRef(null); + + /* ── Date de coupure : 31/12/{platYear} pour les années passées, aujourd'hui pour l'année en cours ou sans filtre ── */ + const todayStr = new Date().toISOString().slice(0, 10); + const currentYear = String(new Date().getFullYear()); + const cutoffStr = platYear && platYear !== currentYear ? `${platYear}-12-31` : todayStr; + + /* ── 1. Courbe capital en cours = investissements − capital remboursé ── */ + const allPoints = useMemo(() => { + if (!rows?.length) return []; + + // Tous les investissements du scope (le capRestant sera 0 pour les remboursés) + const rowIds = new Set(rows.map(r => r.id)); + + // Variations de capital par date + const deltas = {}; + + // +montant_investi à chaque date de souscription + for (const r of rows) { + const d = r.date_souscription?.slice(0, 10); + if (!d || d > cutoffStr) continue; + deltas[d] = (deltas[d] || 0) + (r.montant_investi ?? 0); + } + + // +réinvestissements à leur date propre (dans le scope, avant coupure) + if (reinvestissements?.length) { + for (const rv of reinvestissements) { + if (!rowIds.has(rv.investissement_id)) continue; + const d = rv.date_reinvestissement?.slice(0, 10); + if (!d || !rv.montant || d > cutoffStr) continue; + deltas[d] = (deltas[d] || 0) + rv.montant; + } + } + + // -capital à chaque date de remboursement (investissements du scope, avant coupure) + if (remboursements?.length) { + for (const rb of remboursements) { + if (!rowIds.has(rb.investissement_id)) continue; + const d = rb.date_remb?.slice(0, 10); + if (!d || !rb.capital || d > cutoffStr) continue; + deltas[d] = (deltas[d] || 0) - rb.capital; + } + } + + // Tri chronologique + cumul + let cum = 0; + return Object.keys(deltas).sort().map(date => { + cum += deltas[date]; + return { date, value: Math.max(0, cum) }; + }); + }, [rows, remboursements, reinvestissements, cutoffStr]); + + const currentValue = allPoints.length ? allPoints[allPoints.length - 1].value : 0; + + /* ── 2. Filtrage par plage ── */ + const points = useMemo(() => { + if (!allPoints.length) return []; + if (range === 'TOUT') { + const pts = [...allPoints]; + if (pts[pts.length - 1].date < cutoffStr) pts.push({ date: cutoffStr, value: pts[pts.length - 1].value }); + return pts; + } + const now = new Date(cutoffStr + 'T00:00:00'); + let fromDate = new Date(now); + switch (range) { + case '1J': fromDate.setDate(now.getDate() - 1); break; + case '7J': fromDate.setDate(now.getDate() - 7); break; + case '1M': fromDate.setMonth(now.getMonth() - 1); break; + case '3M': fromDate.setMonth(now.getMonth() - 3); break; + case 'YTD': fromDate = new Date(now.getFullYear(), 0, 1); break; + case '1A': fromDate.setFullYear(now.getFullYear() - 1); break; + } + const fromStr = fromDate.toISOString().slice(0, 10); + const before = allPoints.filter(p => p.date < fromStr); + const startV = before.length ? before[before.length - 1].value : 0; + const after = allPoints.filter(p => p.date >= fromStr); + const pts = [{ date: fromStr, value: startV }, ...after]; + if (pts[pts.length - 1].date < cutoffStr) pts.push({ date: cutoffStr, value: pts[pts.length - 1].value }); + return pts; + }, [allPoints, range, cutoffStr]); + + /* ── 3. Échelles ── */ + const { xScale, yScale, yTicks, xTicks, minDate, maxDate, yZero } = useMemo(() => { + if (points.length < 2) return {}; + const vals = points.map(p => p.value); + const dataMin = Math.min(...vals); + const dataMax = Math.max(...vals); + const lo = Math.min(0, dataMin); + const hi = Math.max(0, dataMax); + const pad = (hi - lo) * 0.1 || 10; + const scaleLo = lo - (lo < 0 ? pad : 0); + const scaleHi = hi + pad; + const valRange = scaleHi - scaleLo || 1; + + const ts = points.map(p => new Date(p.date + 'T00:00:00').getTime()); + const minDt = ts[0]; + const maxDt = ts[ts.length - 1]; + const dtRange = maxDt - minDt || 1; + + const xScale = d => PAD.left + ((new Date(d + 'T00:00:00').getTime() - minDt) / dtRange) * plotW; + const yScale = v => PAD.top + plotH - ((v - scaleLo) / valRange) * plotH; + + const step = (scaleHi - scaleLo) / 4; + const yTicks = Array.from({ length: 5 }, (_, i) => ({ v: scaleLo + step * i, y: yScale(scaleLo + step * i) })); + + const nX = Math.min(8, points.length); + const xTicks = Array.from({ length: nX }, (_, i) => { + const idx = Math.round((i / (nX - 1)) * (points.length - 1)); + return points[idx]; + }); + + return { xScale, yScale, yTicks, xTicks, minDate: minDt, maxDate: maxDt, yZero: yScale(0) }; + }, [points]); + + /* ── 4. Chemins SVG ── */ + const { linePath, areaPath } = useMemo(() => { + if (!xScale || points.length < 2) return { linePath: '', areaPath: '' }; + let line = `M ${xScale(points[0].date).toFixed(1)},${yScale(points[0].value).toFixed(1)}`; + for (let i = 1; i < points.length; i++) { + line += ` H ${xScale(points[i].date).toFixed(1)} V ${yScale(points[i].value).toFixed(1)}`; + } + const zeroY = yZero?.toFixed(1) ?? (PAD.top + plotH).toFixed(1); + const area = `${line} V ${zeroY} H ${PAD.left} Z`; + return { linePath: line, areaPath: area }; + }, [points, xScale, yScale, yZero]); + + /* ── 5. Hover ── */ + const handleMouseMove = (e) => { + if (!svgRef.current || !xScale || points.length < 2) return; + const rect = svgRef.current.getBoundingClientRect(); + const svgX = ((e.clientX - rect.left) / rect.width) * W; + const t = minDate + ((svgX - PAD.left) / plotW) * (maxDate - minDate); + let nearest = points[0], minDiff = Infinity; + for (const p of points) { + const diff = Math.abs(new Date(p.date + 'T00:00:00').getTime() - t); + if (diff < minDiff) { minDiff = diff; nearest = p; } + } + setHover({ x: xScale(nearest.date), y: yScale(nearest.value), value: nearest.value, date: nearest.date }); + }; + + /* ── Tooltip ── */ + const tooltipStyle = useMemo(() => { + if (!hover) return null; + const xPct = (hover.x / W) * 100; + const yPct = (hover.y / H) * 100; + const anchorRight = xPct > 65; + return { + position: 'absolute', + top: `calc(${yPct}% - 64px)`, + left: anchorRight ? 'auto' : `calc(${xPct}% + 12px)`, + right: anchorRight ? `calc(${100 - xPct}% + 12px)` : 'auto', + transform: 'none', + pointerEvents: 'none', + }; + }, [hover]); + + if (!allPoints.length) return null; + + const displayLabel = platYear ? `31/12/${platYear}` : "Aujourd'hui"; + const displayDate = hover + ? new Date(hover.date + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' }) + : displayLabel; + const displayValue = fmtValueDisplay(hover ? hover.value : currentValue); + const tooltipDate = hover + ? new Date(hover.date + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' }) + : null; + + return ( +
+ + {/* ── En-tête ── */} +
+
+
Capital investi · {displayDate}
+
{displayValue}
+
+
+
+ {RANGES.map(r => ( + + ))} +
+
+
+ + {/* ── SVG ── */} + {xScale && ( + setHover(null)} + > + + + + + + + + + + + + + {/* Grille horizontale */} + {yTicks.map(({ v, y }) => ( + + + + {fmtK(v)} + + + ))} + + {/* Ligne zéro */} + {yZero && yZero > PAD.top && yZero < PAD.top + plotH && ( + + )} + + {/* Aire dégradée + courbe */} + + + + {/* Labels axe X */} + {xTicks.map((p, i) => { + const anchor = i === 0 ? 'start' : i === xTicks.length - 1 ? 'end' : 'middle'; + return ( + + {fmtAxisDate(p.date, range)} + + ); + })} + + {/* Hover : ligne verticale + point */} + {hover && ( + + + + + )} + + )} + + {/* ── Tooltip flottant ── */} + {hover && tooltipStyle && ( +
+
+ {tooltipDate} + {fmtValueDisplay(hover.value)} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/InvMensuelTable.jsx b/frontend/src/components/InvMensuelTable.jsx new file mode 100644 index 0000000..b897316 --- /dev/null +++ b/frontend/src/components/InvMensuelTable.jsx @@ -0,0 +1,206 @@ +import { useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { fmtEUR, fmtStatut } from '../utils/format.js'; + +const MOIS_LONG = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre']; + +function endOfMonth(Y, M) { + const d = new Date(Y, M, 0); + return `${Y}-${String(M).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; +} +function startOfMonth(Y, M) { + return `${Y}-${String(M).padStart(2,'0')}-01`; +} + +const STATUT_BG = { + en_cours: 'var(--b-en_cours-bg)', + rembourse: 'var(--b-rembourse-bg)', + en_retard: 'var(--b-en_retard-bg)', + procedure: 'var(--b-procedure-bg)', + cloture: 'var(--surface-2)', +}; +const STATUT_FG = { + en_cours: 'var(--b-en_cours-fg)', + rembourse: 'var(--b-rembourse-fg)', + en_retard: 'var(--b-en_retard-fg)', + procedure: 'var(--b-procedure-fg)', + cloture: 'var(--text-muted)', +}; + +export default function InvMensuelTable({ rows, allRembs, allReinvests, year }) { + const navigate = useNavigate(); + const currentYear = new Date().getFullYear(); + const currentMonth = new Date().getMonth() + 1; + const displayYear = year ? Number(year) : currentYear; + + /* ── Precompute rembs ── */ + const reinvestByInv = useMemo(() => { + const map = {}; + for (const rv of (allReinvests || [])) { + const id = rv.investissement_id; + if (!id) continue; + if (!map[id]) map[id] = []; + map[id].push({ date: rv.date_reinvestissement?.slice(0,10), montant: rv.montant || 0 }); + } + return map; + }, [allReinvests]); + + const capRembByInv = useMemo(() => { + const map = {}; + for (const rb of (allRembs || [])) { + const id = rb.investissement_id; + if (!id || rb.type !== 'normal') continue; + if (!map[id]) map[id] = []; + map[id].push({ date: rb.date_remb?.slice(0,10), capital: rb.capital || 0 }); + } + return map; + }, [allRembs]); + + const lastRembDateMap = useMemo(() => { + const map = {}; + for (const rb of (allRembs || [])) { + const id = rb.investissement_id; + const d = rb.date_remb?.slice(0,10); + if (!id || !d) continue; + if (!map[id] || d > map[id]) map[id] = d; + } + return map; + }, [allRembs]); + + /* ── Capital encours d'un investissement à fin de mois M ── */ + const getCapital = (inv, Y, M) => { + const endM = endOfMonth(Y, M); + if (inv.date_souscription > endM) return 0; + const startM = startOfMonth(Y, M); + const ACTIVE = ['en_cours', 'en_retard', 'procedure']; + const isActive = ACTIVE.includes(inv.statut) || + ((inv.date_cible || lastRembDateMap[inv.id] || null) >= startM); + if (!isActive) return 0; + + const reinvM = (reinvestByInv[inv.id] || []) + .filter(rv => rv.date && rv.date <= endM) + .reduce((s, rv) => s + rv.montant, 0); + const capRembM = (capRembByInv[inv.id] || []) + .filter(rb => rb.date && rb.date <= endM) + .reduce((s, rb) => s + rb.capital, 0); + return Math.max(0, inv.montant_investi + reinvM - capRembM); + }; + + /* ── Grille : une ligne par investissement ── */ + const grid = useMemo(() => { + if (!rows?.length) return []; + return rows + .map(inv => ({ + inv, + months: Array.from({ length: 12 }, (_, i) => getCapital(inv, displayYear, i + 1)), + })) + .filter(r => r.months.some(v => v > 0)) + .sort((a, b) => + (a.inv.date_souscription || '') < (b.inv.date_souscription || '') ? -1 : 1 + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rows, displayYear, reinvestByInv, capRembByInv, lastRembDateMap]); + + const monthTotals = useMemo(() => + Array.from({ length: 12 }, (_, i) => grid.reduce((s, r) => s + r.months[i], 0)), + [grid] + ); + + if (!grid.length) { + return ( +
+ Aucun investissement actif pour {displayYear}. +
+ ); + } + + return ( +
+ + + + + + + + + {MOIS_LONG.map((m, i) => ( + + ))} + + + + {grid.map(({ inv, months }) => ( + navigate(`/investissements/${inv.id}`)}> + + + + {months.map((v, mi) => { + const curClass = displayYear === currentYear && mi === currentMonth - 1 ? ' tip-col-current' : ''; + if (v === 0) { + // Avant la date de souscription + const subYear = Number(inv.date_souscription?.slice(0, 4)); + const subMo = Number(inv.date_souscription?.slice(5, 7)) - 1; + const isBefore = inv.date_souscription && ( + subYear > displayYear || (subYear === displayYear && mi < subMo) + ); + // Après le dernier remboursement (prêt remboursé) + const lastDate = lastRembDateMap[inv.id]; + const isAfter = inv.statut === 'rembourse' && lastDate && (() => { + const lastYear = Number(lastDate.slice(0, 4)); + const lastMo = Number(lastDate.slice(5, 7)) - 1; + if (lastYear < displayYear) return true; + if (lastYear === displayYear) return mi > lastMo; + return false; + })(); + if (isBefore || isAfter) { + return + ); + })} + + ))} + + + + + + ))} + + +
+ + {displayYear}
InvestissementStatut + {m} +
+ {inv.nom_projet || '—'} + + + {fmtStatut(inv.statut)} + + ; + } + } + return ( + + {v > 0 ? fmtEUR(v) : } +
Total + {monthTotals.map((v, i) => ( + + {v > 0 ? fmtEUR(v) : } +
+
+ ); +} diff --git a/frontend/src/components/InvSelect.jsx b/frontend/src/components/InvSelect.jsx new file mode 100644 index 0000000..9d19317 --- /dev/null +++ b/frontend/src/components/InvSelect.jsx @@ -0,0 +1,195 @@ +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { api } from '../api.js'; + +/** + * InvSelect — multi-select with checkboxes + inline "Add item" + * Generic replacement for CategorySelect, works with categories_inv / secteurs_inv. + * + * Props: + * items : { id, nom, is_global }[] — liste complète fournie par le parent + * selected : number[] — ids sélectionnés + * onChange : (ids: number[]) => void + * addApiPath : string — ex. '/categories-inv' | '/secteurs-inv' + * onItemAdded : ({ id, nom, is_global }) => void — appelé après création inline + * emptyLabel : string — texte si rien de sélectionné + * addLabel : string — texte du bouton "Ajouter" + * inputPlaceholder : string — placeholder du champ de création + */ +export default function InvSelect({ + items = [], + selected = [], + onChange, + addApiPath, + onItemAdded, + emptyLabel = 'Aucun élément sélectionné', + addLabel = 'Ajouter un élément', + inputPlaceholder = 'Nom…', + inheritedIds = [], +}) { + const [open, setOpen] = useState(false); + const [adding, setAdding] = useState(false); + const [newName, setNewName] = useState(''); + const [err, setErr] = useState(null); + const [busy, setBusy] = useState(false); + const [dropPos, setDropPos] = useState({ top: 0, left: 0, width: 0 }); + + const wrapRef = useRef(null); + const triggerRef = useRef(null); + + useLayoutEffect(() => { + if (!open || !triggerRef.current) return; + const rect = triggerRef.current.getBoundingClientRect(); + setDropPos({ top: rect.bottom + 4, left: rect.left, width: rect.width }); + }, [open]); + + useEffect(() => { + if (!open) return; + const close = (e) => { + if (wrapRef.current?.contains(e.target)) return; + const drop = document.getElementById('inv-select-dropdown-portal'); + if (drop?.contains(e.target)) return; + setOpen(false); + }; + const closeOnScroll = (e) => { + const drop = document.getElementById('inv-select-dropdown-portal'); + if (drop?.contains(e.target)) return; // scroll dans le dropdown — on garde ouvert + setOpen(false); + }; + document.addEventListener('mousedown', close); + window.addEventListener('scroll', closeOnScroll, true); + window.addEventListener('resize', closeOnScroll); + return () => { + document.removeEventListener('mousedown', close); + window.removeEventListener('scroll', closeOnScroll, true); + window.removeEventListener('resize', closeOnScroll); + }; + }, [open]); + + const toggle = (id) => { + if (inheritedIds.includes(id)) return; // tag hérité du référentiel, non modifiable + onChange(selected.includes(id) ? selected.filter(x => x !== id) : [...selected, id]); + }; + + const addItem = async (e) => { + e.preventDefault(); + if (!newName.trim()) return; + setBusy(true); setErr(null); + try { + const item = await api.post(addApiPath, { nom: newName.trim() }); + onItemAdded?.(item); + onChange([...selected, item.id]); + setNewName(''); + setAdding(false); + } catch (e) { + setErr(e.message); + } finally { + setBusy(false); + } + }; + + const triggerLabel = (() => { + if (selected.length === 0) return emptyLabel; + const names = items.filter(c => selected.includes(c.id)).map(c => c.nom); + if (names.length <= 2) return names.join(', '); + return `${names.length} éléments sélectionnés`; + })(); + + const dropdown = open ? ( +
+ {items.length === 0 && ( +
{emptyLabel}
+ )} + {items.map(item => { + const checked = selected.includes(item.id); + const inherited = inheritedIds.includes(item.id); + return ( + + ); + })} + + {addApiPath && ( + <> +
+ {!adding ? ( + + ) : ( +
+ setNewName(e.target.value)} + placeholder={inputPlaceholder} + maxLength={200} + /> +
+ + +
+ {err &&
{err}
} +
+ )} + + )} +
+ ) : null; + + return ( + <> +
+ +
+ + {dropdown} + + ); +} diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx new file mode 100644 index 0000000..4777614 --- /dev/null +++ b/frontend/src/components/Layout.jsx @@ -0,0 +1,341 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { NavLink, Outlet, useNavigate } from 'react-router-dom'; +import { api } from '../api.js'; +import { useInvestisseur } from '../context/InvestisseurContext.jsx'; +import { useUi } from '../context/UiContext.jsx'; +import Logo from './Logo.jsx'; +import UserMenu from './UserMenu.jsx'; + +/* ── Icônes nav ─────────────────────────────────────────────── */ +const ICONS_BASE = '/api/icons-files/'; + +const I = ({ children }) => ( + +); + +const IconDashboard = () => ; +const IconDeposits = () => ; +const IconInvestments = () => ; +const IconRepayments = () => ; +const IconFlatTax = () => ; + +/* Icône nav hybride : bibliothèque si dispo, sinon fallback SVG inline */ +function NavIcon({ libFilename, Fallback }) { + if (libFilename) { + return ( + + ); + } + return ; +} + +/* Bouton « réduire » (visible dans la sidebar étendue) */ +const IconPanelCollapse = () => ( + +); + +/* Bouton « étendre » (apparaît au hover du logo en mode réduit) */ +const IconPanelExpand = () => ( + +); + +/* ── Recherche rapide de projet ─────────────────────────────── */ +function ProjectSearch() { + const navigate = useNavigate(); + const { activeId, activeView } = useInvestisseur(); + const [query, setQuery] = useState(''); + const [allInv, setAllInv] = useState([]); + const [open, setOpen] = useState(false); + const [activeIdx, setActiveIdx] = useState(-1); + const inputRef = useRef(null); + const wrapRef = useRef(null); + + /* Chargement (ou rechargement) des investissements */ + const loadInv = useCallback(async () => { + try { + const scopeParams = activeView === 'all' ? { scope: 'all' } : {}; + const rows = await api.get('/investissements', scopeParams); + setAllInv(rows); + } catch {} + }, [activeView]); + + useEffect(() => { loadInv(); }, [loadInv, activeId]); + + /* Raccourci clavier global Ctrl+K / Cmd+K */ + useEffect(() => { + const h = e => { + if ((e.ctrlKey || e.metaKey) && e.key === 'k') { + e.preventDefault(); + inputRef.current?.focus(); + inputRef.current?.select(); + } + }; + document.addEventListener('keydown', h); + return () => document.removeEventListener('keydown', h); + }, []); + + /* Fermeture au clic extérieur */ + useEffect(() => { + const h = e => { if (!wrapRef.current?.contains(e.target)) setOpen(false); }; + document.addEventListener('mousedown', h); + return () => document.removeEventListener('mousedown', h); + }, []); + + /* Résultats filtrés */ + const results = (() => { + const q = query.trim().toLowerCase(); + if (!q) return []; + return allInv + .filter(r => r.nom_projet?.toLowerCase().includes(q) || r.plateforme_nom?.toLowerCase().includes(q)) + .slice(0, 8); + })(); + + /* Synchronise l'ouverture du dropdown */ + useEffect(() => { + setOpen(results.length > 0 && query.trim().length > 0); + setActiveIdx(-1); + }, [results.length, query]); /* eslint-disable-line */ + + const goTo = (inv) => { + setQuery(''); setOpen(false); + navigate(`/investissements/${inv.id}`); + }; + + const handleKeyDown = (e) => { + if (e.key === 'ArrowDown') { e.preventDefault(); setActiveIdx(i => Math.min(i + 1, results.length - 1)); } + if (e.key === 'ArrowUp') { e.preventDefault(); setActiveIdx(i => Math.max(i - 1, -1)); } + if (e.key === 'Enter') { e.preventDefault(); if (activeIdx >= 0) goTo(results[activeIdx]); else if (results.length === 1) goTo(results[0]); } + if (e.key === 'Escape') { setOpen(false); setQuery(''); inputRef.current?.blur(); } + }; + + const STATUT_LABELS = { + en_cours: 'en cours', + rembourse: 'remboursé', + en_retard: 'en retard', + procedure: 'procédure', + cloture: 'clôturé', + }; + + const statutColor = (s) => { + if (s === 'en_cours') return 'var(--b-en_cours-fg)'; + if (s === 'rembourse') return 'var(--b-rembourse-fg)'; + if (s === 'en_retard') return 'var(--b-en_retard-fg)'; + if (s === 'procedure') return 'var(--b-procedure-fg)'; + return 'var(--text-muted)'; + }; + + return ( +
+
+ + + + setQuery(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Rechercher un projet…" + autoComplete="off" + spellCheck="false" + /> + {query ? ( + + ) : ( + ⌘K + )} +
+ + {open && ( +
+ {results.map((inv, i) => ( +
goTo(inv)} + onMouseEnter={() => setActiveIdx(i)} + > +
{inv.nom_projet}
+
+ {inv.plateforme_nom} + · + {STATUT_LABELS[inv.statut] ?? inv.statut?.replace('_', ' ')} + {inv.montant_investi != null && ( + <> + · + {inv.montant_investi.toLocaleString('fr-FR')} € + + )} +
+
+ ))} +
+ )} +
+ ); +} + +/* ── Layout ─────────────────────────────────────────────────── */ +function IconPlusCircle() { + return ( + + ); +} + +export default function Layout() { + const { sidebarCollapsed, toggleSidebar, displayMode, setDisplayMode } = useUi(); + const navigate = useNavigate(); + const [navIcons, setNavIcons] = useState({}); + + useEffect(() => { + api.get('/icons').then(rows => { + const m = {}; + rows.forEach(r => { m[r.name] = r.filename; }); + setNavIcons(m); + }).catch(() => {}); + }, []); + + return ( +
+ + +
+
+ +
+ + + + +
+ + +
+
+
+ + +
+
+ ); +} diff --git a/frontend/src/components/Logo.jsx b/frontend/src/components/Logo.jsx new file mode 100644 index 0000000..31f4fbe --- /dev/null +++ b/frontend/src/components/Logo.jsx @@ -0,0 +1,18 @@ +export default function Logo({ size = 32 }) { + return ( + + ); +} diff --git a/frontend/src/components/Modal.jsx b/frontend/src/components/Modal.jsx new file mode 100644 index 0000000..f56e782 --- /dev/null +++ b/frontend/src/components/Modal.jsx @@ -0,0 +1,19 @@ +export default function Modal({ open, title, onClose, children, footer, width = 600 }) { + if (!open) return null; + return ( +
+
e.stopPropagation()} + > +
+

{title}

+ +
+ {children} + {footer &&
{footer}
} +
+
+ ); +} diff --git a/frontend/src/components/PageIcon.jsx b/frontend/src/components/PageIcon.jsx new file mode 100644 index 0000000..b63468b --- /dev/null +++ b/frontend/src/components/PageIcon.jsx @@ -0,0 +1,44 @@ +import { useEffect, useState } from 'react'; +import { api } from '../api.js'; + +const ICONS_BASE = '/api/icons-files/'; + +let _cache = null; +let _promise = null; + +function getIcons() { + if (_cache) return Promise.resolve(_cache); + if (!_promise) { + _promise = api.get('/icons') + .then(rows => { _cache = {}; rows.forEach(r => { _cache[r.name] = r.filename; }); return _cache; }) + .catch(() => { _cache = {}; return _cache; }); + } + return _promise; +} + +export default function PageIcon({ name, size = 40 }) { + const [filename, setFilename] = useState(() => _cache?.[name] ?? null); + + useEffect(() => { + if (_cache) { setFilename(_cache[name] ?? null); return; } + getIcons().then(m => setFilename(m[name] ?? null)); + }, [name]); + + if (!filename) return null; + return ( + + ); +} diff --git a/frontend/src/components/Pagination.jsx b/frontend/src/components/Pagination.jsx new file mode 100644 index 0000000..53f25fe --- /dev/null +++ b/frontend/src/components/Pagination.jsx @@ -0,0 +1,59 @@ +/** + * Barre de pagination réutilisable. + * Props : page, setPage, pageSize, setPageSize, totalPages, totalItems, PAGE_SIZES + */ +export default function Pagination({ page, setPage, pageSize, setPageSize, totalPages, totalItems, PAGE_SIZES }) { + if (totalItems === 0) return null; + + const start = (page - 1) * pageSize + 1; + const end = Math.min(page * pageSize, totalItems); + + return ( +
+ + {start}–{end} sur {totalItems} + + +
+ + + + + {page} / {totalPages} + + +
+
+ ); +} diff --git a/frontend/src/components/ResultBanner.jsx b/frontend/src/components/ResultBanner.jsx new file mode 100644 index 0000000..fc69116 --- /dev/null +++ b/frontend/src/components/ResultBanner.jsx @@ -0,0 +1,51 @@ +import { useEffect } from 'react'; + +/** + * ResultBanner — bannière succès/erreur avec auto-dismiss et × à droite. + * + * Props: + * result : { ok: bool, msg: string } | null + * onDismiss : () => void — appelé à la fermeture (manuelle ou auto) + * delay : number — délai auto-dismiss en ms (défaut 4000) + */ +export default function ResultBanner({ result, onDismiss, delay = 4000, style = {} }) { + useEffect(() => { + if (!result) return; + const t = setTimeout(onDismiss, delay); + return () => clearTimeout(t); + }, [result, delay, onDismiss]); + + if (!result) return null; + + return ( +
+ {result.msg} + +
+ ); +} diff --git a/frontend/src/components/SoldeChart.jsx b/frontend/src/components/SoldeChart.jsx new file mode 100644 index 0000000..64d889f --- /dev/null +++ b/frontend/src/components/SoldeChart.jsx @@ -0,0 +1,297 @@ +import { useMemo, useState, useRef, useEffect } from 'react'; + +/* ── Constantes ─────────────────────────────────────────────── */ +const GOLD = '#4fa8e8'; // bleu ciel — accord avec le thème navy du site +const BG = '#070c15'; +const GRID = 'rgba(255,255,255,0.055)'; +const LABEL = '#4a5568'; + +const RANGES = ['1J', '7J', '1M', '3M', 'YTD', '1A', 'TOUT']; + +const MOIS_COURT = ['jan.','fév.','mar.','avr.','mai','juin','juil.','août','sep.','oct.','nov.','déc.']; + +/* ── Helpers ────────────────────────────────────────────────── */ +function fmtK(v) { + if (v === 0) return '0 €'; + const abs = Math.abs(v); + if (abs >= 1000) return (v / 1000).toLocaleString('fr-FR', { maximumFractionDigits: 0 }) + ' k €'; + return v.toLocaleString('fr-FR', { maximumFractionDigits: 0 }) + ' €'; +} + +function fmtAxisDate(dateStr, range) { + const d = new Date(dateStr + 'T00:00:00'); + const day = String(d.getDate()).padStart(2, '0'); + const mon = MOIS_COURT[d.getMonth()]; + const yr = d.getFullYear(); + if (range === '1J' || range === '7J') return `${day}/${String(d.getMonth()+1).padStart(2,'0')}`; + if (range === '1M') return `${day} ${mon}`; + if (range === '3M') return `${day} ${mon}`; + return `${day}/${String(d.getMonth()+1).padStart(2,'0')}/${String(yr).slice(2)}`; +} + +function fmtValueDisplay(v) { + return v.toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 0 }) + ' €'; +} + +function fmtTodayFull() { + return new Date().toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' }); +} + +/* ── Composant ──────────────────────────────────────────────── */ +export default function SoldeChart({ rows }) { + const [range, setRange] = useState('TOUT'); + const [hover, setHover] = useState(null); // { x, y, value, date } + const svgRef = useRef(null); + const wrapRef = useRef(null); + + /* ── 1. Cumul complet (toutes les données) ── */ + const allPoints = useMemo(() => { + if (!rows?.length) return []; + const byDate = {}; + for (const r of rows) { + const d = r.date_operation.slice(0, 10); + byDate[d] = (byDate[d] || 0) + (r.type === 'depot' ? r.montant : -r.montant); + } + let cum = 0; + return Object.keys(byDate).sort().map(date => { + cum += byDate[date]; + return { date, value: cum }; + }); + }, [rows]); + + const todayStr = new Date().toISOString().slice(0, 10); + const currentValue = allPoints.length ? allPoints[allPoints.length - 1].value : 0; + + /* ── 2. Filtrage par plage ── */ + const points = useMemo(() => { + if (!allPoints.length) return []; + if (range === 'TOUT') { + const pts = [...allPoints]; + if (pts[pts.length - 1].date < todayStr) pts.push({ date: todayStr, value: pts[pts.length - 1].value }); + return pts; + } + const now = new Date(); + let fromDate = new Date(now); + switch (range) { + case '1J': fromDate.setDate(now.getDate() - 1); break; + case '7J': fromDate.setDate(now.getDate() - 7); break; + case '1M': fromDate.setMonth(now.getMonth() - 1); break; + case '3M': fromDate.setMonth(now.getMonth() - 3); break; + case 'YTD': fromDate = new Date(now.getFullYear(), 0, 1); break; + case '1A': fromDate.setFullYear(now.getFullYear() - 1); break; + } + const fromStr = fromDate.toISOString().slice(0, 10); + const before = allPoints.filter(p => p.date < fromStr); + const startV = before.length ? before[before.length - 1].value : 0; + const after = allPoints.filter(p => p.date >= fromStr); + const pts = [{ date: fromStr, value: startV }, ...after]; + if (pts[pts.length - 1].date < todayStr) pts.push({ date: todayStr, value: pts[pts.length - 1].value }); + return pts; + }, [allPoints, range, todayStr]); + + /* ── 3. SVG dimensions ── */ + const W = 900, H = 260; + const PAD = { top: 16, right: 16, bottom: 32, left: 70 }; + const plotW = W - PAD.left - PAD.right; + const plotH = H - PAD.top - PAD.bottom; + + /* ── 4. Échelles ── */ + const { xScale, yScale, yTicks, xTicks, minDate, maxDate, yZero } = useMemo(() => { + if (points.length < 2) return {}; + const vals = points.map(p => p.value); + const dataMin = Math.min(...vals); + const dataMax = Math.max(...vals); + // Inclure 0 pour ancrer l'axe ; ajouter 10 % de padding + const lo = Math.min(0, dataMin); + const hi = Math.max(0, dataMax); + const pad = (hi - lo) * 0.1 || 10; + const scaleLo = lo - (lo < 0 ? pad : 0); + const scaleHi = hi + (hi > 0 ? pad : pad); + const valRange = scaleHi - scaleLo || 1; + + const ts = points.map(p => new Date(p.date + 'T00:00:00').getTime()); + const minDt = ts[0]; + const maxDt = ts[ts.length - 1]; + const dtRange = maxDt - minDt || 1; + + const xScale = d => PAD.left + ((new Date(d + 'T00:00:00').getTime() - minDt) / dtRange) * plotW; + const yScale = v => PAD.top + plotH - ((v - scaleLo) / valRange) * plotH; + + // Y ticks : 5 niveaux couvrant la plage réelle + const step = (scaleHi - scaleLo) / 4; + const yTicks = Array.from({ length: 5 }, (_, i) => ({ v: scaleLo + step * i, y: yScale(scaleLo + step * i) })); + + // X ticks : max 8 + const nX = Math.min(8, points.length); + const xTicks = Array.from({ length: nX }, (_, i) => { + const idx = Math.round((i / (nX - 1)) * (points.length - 1)); + return points[idx]; + }); + + return { xScale, yScale, yTicks, xTicks, minDate: minDt, maxDate: maxDt, yZero: yScale(0) }; + }, [points, plotW, plotH, PAD]); + + /* ── 5. Chemins SVG ── */ + const { linePath, areaPath } = useMemo(() => { + if (!xScale || points.length < 2) return { linePath: '', areaPath: '' }; + let line = `M ${xScale(points[0].date).toFixed(1)},${yScale(points[0].value).toFixed(1)}`; + for (let i = 1; i < points.length; i++) { + line += ` H ${xScale(points[i].date).toFixed(1)} V ${yScale(points[i].value).toFixed(1)}`; + } + // Refermer l'aire sur la ligne zéro (et non le bas du chart) + const zeroY = yZero?.toFixed(1) ?? (PAD.top + plotH).toFixed(1); + const area = `${line} V ${zeroY} H ${PAD.left} Z`; + return { linePath: line, areaPath: area }; + }, [points, xScale, yScale, yZero, PAD, plotH]); + + /* ── 6. Hover ── */ + const handleMouseMove = (e) => { + if (!svgRef.current || !xScale || points.length < 2) return; + const rect = svgRef.current.getBoundingClientRect(); + const svgX = ((e.clientX - rect.left) / rect.width) * W; + const t = minDate + ((svgX - PAD.left) / plotW) * (maxDate - minDate); + // Find nearest point + let nearest = points[0], minDiff = Infinity; + for (const p of points) { + const diff = Math.abs(new Date(p.date + 'T00:00:00').getTime() - t); + if (diff < minDiff) { minDiff = diff; nearest = p; } + } + setHover({ + x: xScale(nearest.date), + y: yScale(nearest.value), + value: nearest.value, + date: nearest.date, + }); + }; + + /* ── Tooltip flottant : DOIT être avant tout return conditionnel (Rules of Hooks) ── */ + const tooltipStyle = useMemo(() => { + if (!hover) return null; + const xPct = (hover.x / W) * 100; + const yPct = (hover.y / H) * 100; + const anchorRight = xPct > 65; + return { + position: 'absolute', + top: `calc(${yPct}% - 64px)`, + left: anchorRight ? 'auto' : `calc(${xPct}% + 12px)`, + right: anchorRight ? `calc(${100 - xPct}% + 12px)` : 'auto', + transform: 'none', + pointerEvents: 'none', + }; + }, [hover]); + + if (!allPoints.length) return null; + + /* ── Date affichée dans l'en-tête (hover ou aujourd'hui) ── */ + const displayDate = hover + ? new Date(hover.date + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' }) + : fmtTodayFull(); + const displayValue = hover ? fmtValueDisplay(hover.value) : fmtValueDisplay(currentValue); + + const tooltipDate = hover + ? new Date(hover.date + 'T00:00:00').toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' }) + : null; + + return ( +
+ {/* ── En-tête ── */} +
+
+
{displayDate}
+
{displayValue}
+
+
+
+ {RANGES.map(r => ( + + ))} +
+
+
+ + {/* ── SVG ── */} + {xScale && ( + setHover(null)} + > + + + + + + + + + + + + + {/* Grille horizontale */} + {yTicks.map(({ v, y }) => ( + + + + {fmtK(v)} + + + ))} + + {/* Ligne zéro */} + {yZero && yZero > PAD.top && yZero < PAD.top + plotH && ( + + )} + + {/* Remplissage dégradé */} + + + {/* Ligne principale */} + + + {/* Labels axe X */} + {xTicks.map((p, i) => { + const anchor = i === 0 ? 'start' : i === xTicks.length - 1 ? 'end' : 'middle'; + return ( + + {fmtAxisDate(p.date, range)} + + ); + })} + + {/* Ligne verticale + point hover */} + {hover && ( + + + + + )} + + )} + + {/* ── Tooltip flottant ── */} + {hover && tooltipStyle && ( +
+
+ {tooltipDate} + {fmtValueDisplay(hover.value)} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/TableauInteretsPlateforme.jsx b/frontend/src/components/TableauInteretsPlateforme.jsx new file mode 100644 index 0000000..9c4ee4b --- /dev/null +++ b/frontend/src/components/TableauInteretsPlateforme.jsx @@ -0,0 +1,499 @@ +import { useEffect, useState, useRef, useMemo } from 'react'; +import { api } from '../api.js'; +import { useInteretsChart } from '../context/InteretsChartContext.jsx'; +import { fmtEUR, fmtPct } from '../utils/format.js'; + +const ICONS_BASE = '/api/icons-files/'; +const MOIS_LONG = ['Janvier','Février','Mars','Avril','Mai','Juin','Juillet','Août','Septembre','Octobre','Novembre','Décembre']; + +function hexToRgba(hex, a) { + if (!hex || hex.length < 7) return `rgba(79,168,232,${a})`; + const r = parseInt(hex.slice(1,3),16), g = parseInt(hex.slice(3,5),16), b = parseInt(hex.slice(5,7),16); + return `rgba(${r},${g},${b},${a})`; +} + +function ChevronDown({ size = 10 }) { + return ( + + + + ); +} + +/* ── Fusionne deux maps de remboursements ou projections ── */ +function mergeMaps(mapA, mapB) { + const result = {}; + const keys = new Set([...Object.keys(mapA || {}), ...Object.keys(mapB || {})]); + for (const k of keys) { + const a = mapA?.[k] || {}; + const b = mapB?.[k] || {}; + result[k] = { + interets_bruts: (a.interets_bruts || 0) + (b.interets_bruts || 0), + interets_nets: (a.interets_nets || 0) + (b.interets_nets || 0), + cashback: (a.cashback || 0) + (b.cashback || 0), + capital: (a.capital || 0) + (b.capital || 0), + interets_prevus: (a.interets_prevus || 0) + (b.interets_prevus || 0), + capital_prevu: (a.capital_prevu || 0) + (b.capital_prevu || 0), + }; + } + return result; +} + +export default function TableauInteretsPlateforme({ activeView, activeId, pfuRates, onCapitalMensuel, expandButton, onCellClick, activeCell }) { + const { + annee, setAnnee, availableYears, + inclureInterets, setInclureInterets, + inclureCapital, setInclureCapital, + inclureCashback, setInclureCashback, + netMode, + showActual, toggleActual, + showProjected, toggleProjected, + modeGlobal, toggleModeGlobal, + currentYear, currentMonth, + chartInterets, chartCapital, chartCashback, + } = useInteretsChart(); + + const [data, setData] = useState(null); + const [libIcons, setLibIcons] = useState({}); + const [windowStart, setWindowStart] = useState(0); + const initializedRef = useRef(false); + + /* ── Toggle consolidation détenteurs (clé partagée avec CapitalMensuelTable) ── */ + const [groupByNom, setGroupByNom] = useState(() => { + try { return localStorage.getItem('cl_tip_group_by_nom') === 'true'; } catch { return false; } + }); + const toggleGroupByNom = () => { + setGroupByNom(v => { + const next = !v; + try { localStorage.setItem('cl_tip_group_by_nom', String(next)); } catch {} + return next; + }); + }; + + /* ── Icônes bibliothèque ─────────────────────────────────────── */ + useEffect(() => { + api.get('/icons').then(rows => { + const m = {}; + rows.forEach(r => { m[r.name] = r.filename; }); + setLibIcons(m); + }).catch(() => {}); + }, []); + + /* ── Fenêtre années ──────────────────────────────────────────── */ + const canPrev = windowStart > 0; + const canNext = windowStart + 3 < availableYears.length; + const visibleYears = availableYears.length ? availableYears.slice(windowStart, windowStart + 3) : [annee]; + + useEffect(() => { + if (!availableYears.length || initializedRef.current) return; + initializedRef.current = true; + const idx = availableYears.indexOf(annee); + const safe = idx >= 0 ? idx : availableYears.length - 1; + setWindowStart(Math.max(0, Math.min(availableYears.length - 3, safe - 1))); + }, [availableYears]); + + /* ── Réduction PFU ───────────────────────────────────────────── */ + const pfuReduction = useMemo(() => { + if (!pfuRates?.length) return 0; + const r = pfuRates.find(r => r.annee === annee) + ?? pfuRates.reduce((best, r) => r.annee > best.annee ? r : best, pfuRates[0]); + return (r.prelev_sociaux + r.impot_revenu) / 100; + }, [pfuRates, annee]); + + /* ── Fetch données ───────────────────────────────────────────── */ + useEffect(() => { + if (modeGlobal) { setData(null); onCapitalMensuel?.([]); return; } + const params = { annee, ...(activeView === 'all' ? { scope: 'all' } : {}) }; + api.get('/dashboard/interets-par-plateforme', params) + .then(d => { setData(d); onCapitalMensuel?.(d.capitalMensuel ?? []); }) + .catch(() => {}); + }, [annee, activeView, activeId, modeGlobal]); + + /* ── Helpers affichage ───────────────────────────────────────── */ + const AppIcon = ({ name, size = 28, active = false }) => { + const filename = libIcons[name]; + if (filename) return ( + + ); + return ; + }; + + const plateformes = data?.plateformes ?? []; + const capitalMensuel = data?.capitalMensuel ?? []; + + // N'afficher le détenteur que s'il y en a plusieurs distincts (pattern multiDetenteur) + const multiDetenteur = new Set(plateformes.map(p => p.detenteur_nom).filter(Boolean)).size > 1; + + /* ── Consolidation par nom si demandée ──────────────────────── */ + const displayPlateformes = useMemo(() => { + if (!groupByNom || !multiDetenteur) return plateformes; + const byNom = {}; + for (const plat of plateformes) { + if (!byNom[plat.nom]) { + byNom[plat.nom] = { + ...plat, + id: plat.nom, + detenteur_nom: null, + rembourses: { ...plat.rembourses }, + projections: { ...plat.projections }, + }; + } else { + byNom[plat.nom].rembourses = mergeMaps(byNom[plat.nom].rembourses, plat.rembourses); + byNom[plat.nom].projections = mergeMaps(byNom[plat.nom].projections, plat.projections); + } + } + return Object.values(byNom); + }, [plateformes, groupByNom, multiDetenteur]); + + if (modeGlobal || !data || plateformes.length === 0) { + return null; + } + + /* ── Valeurs par plateforme/mois ──────────────────────────────── + * getCellValue : pour l'affichage (interets + cashback + capital selon toggles) + * getPerfValue : pour la performance (interets + cashback uniquement, jamais capital) + * ─────────────────────────────────────────────────────────────── */ + const buildValue = (plat, mIdx, { withCapital }) => { + const m = mIdx + 1; + const moisStr = `${annee}-${String(m).padStart(2, '0')}`; + const isFuture = annee > currentYear || (annee === currentYear && m > currentMonth); + const isCurrent = annee === currentYear && m === currentMonth; + + if (isFuture) { + if (!showProjected) return null; + const proj = plat.projections[moisStr]; + if (!proj) return null; + let v = 0; + if (inclureInterets) v += netMode ? proj.interets_prevus * (1 - pfuReduction) : proj.interets_prevus; + if (withCapital && inclureCapital) v += proj.capital_prevu ?? 0; + return v > 0 ? { value: v, projected: true } : null; + } + + if (isCurrent) { + const remb = plat.rembourses[moisStr]; + const proj = plat.projections[moisStr]; + let real = 0; + if (showActual && remb) { + if (inclureInterets) real += netMode ? remb.interets_nets : remb.interets_bruts; + if (inclureCashback) real += remb.cashback ?? 0; + if (withCapital && inclureCapital) real += remb.capital ?? 0; + } + let projAmt = 0; + // Les projections backend sont déjà filtrées NOT EXISTS par investissement → pas de double-comptage + if (showProjected && proj) { + if (inclureInterets) projAmt += netMode ? proj.interets_prevus * (1 - pfuReduction) : proj.interets_prevus; + if (withCapital && inclureCapital) projAmt += proj.capital_prevu ?? 0; + } + const val = real + projAmt; + return val > 0 ? { value: val, projected: projAmt > 0 } : null; + } + + // Mois passé + if (!showActual) return null; + const remb = plat.rembourses[moisStr]; + if (!remb) return null; + let v = 0; + if (inclureInterets) v += netMode ? remb.interets_nets : remb.interets_bruts; + if (inclureCashback) v += remb.cashback ?? 0; + if (withCapital && inclureCapital) v += remb.capital ?? 0; + return v > 0 ? { value: v, projected: false } : null; + }; + + const getCellValue = (plat, mIdx) => buildValue(plat, mIdx, { withCapital: true }); + const getPerfValue = (plat, mIdx) => buildValue(plat, mIdx, { withCapital: false }); + + /* ── Grille ──────────────────────────────────────────────────── */ + const grid = displayPlateformes.map(plat => ({ + ...plat, + months: Array.from({ length: 12 }, (_, i) => getCellValue(plat, i)), + })); + + const monthTotals = Array.from({ length: 12 }, (_, i) => + grid.reduce((s, row) => s + (row.months[i]?.value ?? 0), 0)); + const platTotals = grid.map(row => + row.months.reduce((s, v) => s + (v?.value ?? 0), 0)); + const grandTotal = monthTotals.reduce((s, v) => s + v, 0); + + /* Totaux pour la performance : intérêts + cashback uniquement (sans capital) */ + const perfMonthTotals = Array.from({ length: 12 }, (_, i) => + displayPlateformes.reduce((s, plat) => s + (getPerfValue(plat, i)?.value ?? 0), 0)); + const perfGrandTotal = perfMonthTotals.reduce((s, v) => s + v, 0); + + /* ── Capital et performances ─────────────────────────────────── */ + const capitalValues = capitalMensuel.map(c => c.capital); + const nonZeroCap = capitalValues.filter(v => v > 0); + const avgCapital = nonZeroCap.length ? nonZeroCap.reduce((s, v) => s + v, 0) / nonZeroCap.length : 0; + const lastCapital = [...capitalValues].reverse().find(v => v > 0) ?? avgCapital; + + const perfMensuelle = Array.from({ length: 12 }, (_, i) => + capitalValues[i] > 0 ? perfMonthTotals[i] / capitalValues[i] : null); + const perfAnnualisee = perfMensuelle.map(p => p !== null ? p * 12 : null); + const perfAnnTotale = lastCapital > 0 ? perfGrandTotal / lastCapital : null; + + /* ── Label total header ──────────────────────────────────────── */ + const activeTypes = [ + inclureInterets && { color: chartInterets, label: netMode ? 'Intérêts nets' : 'Intérêts bruts' }, + inclureCapital && { color: chartCapital, label: 'Capital' }, + inclureCashback && { color: chartCashback, label: 'Cashback' }, + ].filter(Boolean); + + /* ── Rendu ───────────────────────────────────────────────────── */ + return ( +
+ + {/* ── Header identique au bar chart ── */} +
+
+
+ {inclureInterets && ( + + + + {netMode ? 'Intérêts nets' : 'Intérêts bruts'} + + + )} + {inclureCapital && ( + + + Capital + + )} + {inclureCashback && ( + + + Cashback + + )} + {!inclureInterets && !inclureCapital && !inclureCashback && ( + + )} + · {annee} +
+
{fmtEUR(grandTotal)}
+
+ +
+ {/* Bouton intérêts */} + + {/* Bouton capital */} + + {/* Bouton cashback */} + + + {/* Sélecteur d'années */} +
+ + {visibleYears.map(y => ( + + ))} + + + {expandButton} +
+
+
+ + {/* ── Tableau ── */} +
+ + + + + + + + {MOIS_LONG.map((m, i) => ( + + ))} + + + + + + + {grid.map((plat, pi) => ( + + + {plat.months.map((v, mi) => { + const isCurrent = annee === currentYear && mi === currentMonth - 1; + const cellKey = `${plat.id}:${annee}-${String(mi + 1).padStart(2,'0')}`; + const isActive = activeCell?.key === cellKey; + const clickable = !!v; + return ( + + ); + })} + + + + ))} + + + + + + {monthTotals.map((v, i) => ( + + ))} + + + + + + {capitalValues.map((v, i) => ( + + ))} + + + + + {perfMensuelle.map((v, i) => ( + + ))} + + + + + {perfAnnualisee.map((v, i) => ( + + ))} + + + +
+ {annee} + +
+ + Plateforme + {multiDetenteur && ( + + )} + + {m}TotalMoy. mensuelle
+ {plat.nom} + {!groupByNom && multiDetenteur && plat.detenteur_nom && ( + + {plat.detenteur_nom} + + )} + clickable && onCellClick && onCellClick({ + key: cellKey, + platId: plat.id, + platNom: plat.nom, + annee, + mois: String(mi + 1).padStart(2, '0'), + moisLabel: MOIS_LONG[mi], + })} + > + {v ? fmtEUR(v.value) : } + + {platTotals[pi] > 0 ? fmtEUR(platTotals[pi]) : } + + {platTotals[pi] > 0 ? fmtEUR(platTotals[pi] / 12) : } +
Toutes les plateformes + {v > 0 ? fmtEUR(v) : } + {fmtEUR(grandTotal)}{grandTotal > 0 ? fmtEUR(grandTotal / 12) : }
Capital investi + {v > 0 ? fmtEUR(v) : } + {lastCapital > 0 ? fmtEUR(lastCapital) : } +
{netMode ? "Performance nette mensuelle" : "Performance brute mensuelle"} + {v !== null ? fmtPct(v * 100) : } + + {perfAnnTotale !== null ? fmtPct((perfAnnTotale / 12) * 100) : } + +
{netMode ? "Performance nette annualisée" : "Performance brute annualisée"} + {v !== null ? fmtPct(v * 100) : } + + {perfAnnTotale !== null ? fmtPct(perfAnnTotale * 100) : } + +
+
+ + {/* ── Sélecteur Reçu / Projeté ── */} +
+
+ {[ + { key:'actual', label:'Reçu', active:showActual, toggle:toggleActual }, + { key:'projected', label:'Projeté', active:showProjected, toggle:toggleProjected }, + ].map(btn => ( + + ))} +
+
+ +
+ ); +} diff --git a/frontend/src/components/ThemeSwitcher.jsx b/frontend/src/components/ThemeSwitcher.jsx new file mode 100644 index 0000000..bab5f61 --- /dev/null +++ b/frontend/src/components/ThemeSwitcher.jsx @@ -0,0 +1,28 @@ +import { useTheme } from '../context/ThemeContext.jsx'; + +const OPTIONS = [ + { mode: 'light', icon: '☀', label: 'Clair' }, + { mode: 'dark', icon: '☾', label: 'Sombre' }, + { mode: 'system', icon: '◐', label: 'Système' }, +]; + +export default function ThemeSwitcher() { + const { mode, setMode } = useTheme(); + return ( +
+ {OPTIONS.map(o => ( + + ))} +
+ ); +} diff --git a/frontend/src/components/UserMenu.jsx b/frontend/src/components/UserMenu.jsx new file mode 100644 index 0000000..5fbe5c5 --- /dev/null +++ b/frontend/src/components/UserMenu.jsx @@ -0,0 +1,353 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { createPortal } from 'react-dom'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext.jsx'; +import { useUi } from '../context/UiContext.jsx'; +import { useInvestisseur } from '../context/InvestisseurContext.jsx'; +import { memberInitials, memberLabel } from '../utils/format.js'; + +/* ── Icons ───────────────────────────────────────────────────── */ +function IconUser() { + return ; +} +function IconLogout() { + return ; +} +function IconAdmin() { + return ; +} +function IconSettings() { + return ; +} +function IconAide() { + return ; +} +function IconChevronRight() { + return ; +} +function IconChevronUp({ open }) { + return ( + + ); +} +function IconCheck() { + return ; +} +function IconTeam() { + return ; +} + +/* ── Avatars ─────────────────────────────────────────────────── */ +function UserAvatar({ user, size = 36 }) { + const initials = user?.display_name + ? user.display_name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase() + : (user?.email || '?')[0].toUpperCase(); + return ( +
+ {initials} +
+ ); +} + +function MemberAvatar({ member, size = 28 }) { + const isEntreprise = member?.type === 'entreprise'; + const bg = isEntreprise + ? 'linear-gradient(135deg, #3730a3, #4338ca)' + : 'linear-gradient(135deg, #1e3a8a, #1e40af)'; + return ( +
+ {memberInitials(member)} +
+ ); +} + +function TeamBadge({ size = 28 }) { + return ( +
+ +
+ ); +} + +/* ── Composant principal ─────────────────────────────────────── */ +export default function UserMenu() { + const { user, logout, isAdmin } = useAuth(); + const { sidebarCollapsed } = useUi(); + const { investisseurs, activeView, activeViewMember, setActiveView } = useInvestisseur(); + const navigate = useNavigate(); + + const [open, setOpen] = useState(false); + const [subOpen, setSubOpen] = useState(false); + const [popupStyle, setPopupStyle] = useState({}); + const [subStyle, setSubStyle] = useState({}); + + const triggerRef = useRef(null); + const popupRef = useRef(null); + const subRef = useRef(null); + const closeTimer = useRef(null); + + const famille = investisseurs.filter(i => i.type !== 'entreprise'); + const entreprises = investisseurs.filter(i => i.type === 'entreprise'); + + const POPUP_W = 250; + const SUB_W = 230; + + /* ── Calcul position (fixed = échappe overflow:hidden) ────── */ + const computePosition = useCallback(() => { + if (!triggerRef.current) return; + const r = triggerRef.current.getBoundingClientRect(); + + let mainLeft, mainBottom, mainWidth; + if (sidebarCollapsed) { + mainLeft = r.right + 8; + mainBottom = window.innerHeight - r.bottom; + mainWidth = POPUP_W; + } else { + mainLeft = r.left; + mainBottom = window.innerHeight - r.top + 6; + mainWidth = r.width; + } + + setPopupStyle({ + position: 'fixed', left: mainLeft, bottom: mainBottom, + width: mainWidth, top: 'auto', right: 'auto', + }); + setSubStyle({ + position: 'fixed', + left: mainLeft + (sidebarCollapsed ? POPUP_W : mainWidth) + 6, + bottom: mainBottom, + width: SUB_W, top: 'auto', right: 'auto', + }); + }, [sidebarCollapsed]); + + const openMenu = useCallback(() => { computePosition(); setOpen(true); }, [computePosition]); + const closeMenu = useCallback(() => { setOpen(false); setSubOpen(false); }, []); + + const clearClose = () => clearTimeout(closeTimer.current); + const scheduleClose = () => { closeTimer.current = setTimeout(closeMenu, 200); }; + + /* Fermeture clic extérieur */ + useEffect(() => { + if (!open) return; + const onDown = (e) => { + const inTrigger = triggerRef.current?.contains(e.target); + const inPopup = popupRef.current?.contains(e.target); + const inSub = subRef.current?.contains(e.target); + if (!inTrigger && !inPopup && !inSub) closeMenu(); + }; + document.addEventListener('mousedown', onDown); + return () => document.removeEventListener('mousedown', onDown); + }, [open, closeMenu]); + + /* Recalcul si sidebar change */ + useEffect(() => { if (open) computePosition(); }, [sidebarCollapsed, open, computePosition]); + + /* Handlers ouverture */ + const onWrapEnter = () => { if (sidebarCollapsed) { clearClose(); openMenu(); } }; + const onWrapLeave = () => { if (sidebarCollapsed) scheduleClose(); }; + const onTriggerClick = () => { if (!sidebarCollapsed) { open ? closeMenu() : openMenu(); } }; + + const go = (path) => { closeMenu(); navigate(path); }; + const handleLogout = () => { closeMenu(); logout(); navigate('/login'); }; + + const selectView = (v) => { + setActiveView(v); + closeMenu(); + }; + + /* ── Libellés ────────────────────────────────────────────── */ + const viewLabel = activeView === 'all' + ? 'Famille et entreprises' + : (activeViewMember ? memberLabel(activeViewMember) : 'Famille et entreprises'); + + const TriggerBadge = activeView === 'all' + ? + : ; + + return ( +
+ {/* ── Main Popup (portail → échappe tout stacking context) ── */} + {open && createPortal( +