Merge master into main
@@ -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
|
||||
@@ -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/
|
||||
@@ -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 `<Modal open={bool} title="…" onClose={fn} footer={<>…</>} width={680}>`.
|
||||
|
||||
### Gestion des erreurs form
|
||||
Pattern : `const [err, setErr] = useState(null)` + `{err && <div className="error">{err}</div>}`.
|
||||
|
||||
### 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 `<html>`)
|
||||
- `[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`.
|
||||
@@ -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 && <div className="error">{err}</div>}
|
||||
```
|
||||
|
||||
### Modales
|
||||
```jsx
|
||||
<Modal open={bool} title="…" onClose={fn} footer={<>…</>} width={680}>
|
||||
```
|
||||
|
||||
### Pas de couleurs hardcodées
|
||||
Toujours `var(--primary)`, `var(--success)`, `var(--danger)`, `var(--text)`, `var(--text-muted)`, `var(--border)`, `var(--surface-2)`, etc.
|
||||
|
||||
---
|
||||
|
||||
## Types / énumérations de référence
|
||||
|
||||
| Entité | Valeurs |
|
||||
|---|---|
|
||||
| `investissements.statut` | `en_cours` \| `rembourse` \| `en_retard` \| `procedure` \| `cloture` |
|
||||
| `investissements.type_remb` | `in_fine` \| `amortissable` \| `differe` |
|
||||
| `investissements.freq_interets` | `mensuel` \| `trimestriel` \| `in_fine` |
|
||||
| `remboursements.type` | `normal` \| `bonus_parrainage` \| `bonus_plateforme` |
|
||||
| `remboursements.statut` | `paye` \| `retard` \| `partiel` \| `impaye` |
|
||||
| `depots_retraits.type` | `depot` \| `retrait` |
|
||||
| `investisseurs.type` | `famille` \| `entreprise` |
|
||||
| `users.role` | `user` \| `admin` |
|
||||
|
||||
---
|
||||
|
||||
## Sections Settings
|
||||
|
||||
| Section (`?section=`) | Contenu |
|
||||
|---|---|
|
||||
| `apparence` (défaut) | thème, police, langue, devise |
|
||||
| `plateformes` | CRUD plateformes + catégories |
|
||||
| `categories` | CRUD catégories de plateformes |
|
||||
| `garanties` | référentiel garanties |
|
||||
| `pfu` | taux PFU par année |
|
||||
| `notation` | critères notation par plateforme |
|
||||
| `imports` | import CSV/XLS |
|
||||
|
||||
---
|
||||
|
||||
## Endpoints API principaux
|
||||
|
||||
```
|
||||
POST /api/auth/register | login | me
|
||||
GET/POST/PUT/DELETE /api/investisseurs
|
||||
GET/POST/PUT/DELETE /api/plateformes
|
||||
GET/POST/PUT/DELETE /api/depots-retraits
|
||||
GET/POST/PUT/DELETE /api/investissements
|
||||
GET/POST/PUT/DELETE /api/remboursements
|
||||
GET/POST/DELETE /api/simul
|
||||
POST /api/simul/generate
|
||||
GET /api/dashboard
|
||||
GET /api/fiscal-2778?annee=YYYY
|
||||
GET /api/fiscal-2778/export?annee=YYYY
|
||||
GET/POST /api/pfu
|
||||
GET/POST/PUT/DELETE /api/notation
|
||||
GET/POST/PUT/DELETE /api/garanties
|
||||
GET/POST/PUT/DELETE /api/categories
|
||||
POST /api/imports/preview | apply
|
||||
GET /api/imports/history
|
||||
GET/POST/PUT/DELETE /api/admin/users (requireAdmin)
|
||||
```
|
||||
|
||||
Headers requis (hors `/auth/*`) : `Authorization: Bearer <jwt>` + `X-Investisseur-Id: <id>` pour routes scopées.
|
||||
|
||||
---
|
||||
|
||||
## Démarrage local
|
||||
|
||||
```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.
|
||||
@@ -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 <jwt>`
|
||||
- header `X-Investisseur-Id: <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
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
data
|
||||
uploads
|
||||
.env
|
||||
.env.*
|
||||
npm-debug.log
|
||||
*.log
|
||||
@@ -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"]
|
||||
@@ -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');
|
||||
@@ -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();
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
@@ -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)`);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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); }
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 <svg>
|
||||
out = out.replace(
|
||||
/(<svg\b[^>]*)\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 <rect> elements at origin covering the canvas
|
||||
out = out.replace(/<rect(\s[^>]*)?\/?>/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 <path> elements covering the canvas
|
||||
// (raster-trace backgrounds from design tools like GIMP/Inkscape export)
|
||||
out = out.replace(/<path\b[^>]*\/?>/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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"Champ référentiel";"Champ plateformes";"Hérité";"Notes"
|
||||
"id";"id";"Non";"Clés primaires indépendantes"
|
||||
"nom";"nom";"Oui";"HERITABLE_FIELDS"
|
||||
"url";"url";"Non";"Présent dans les deux; pas hérité"
|
||||
"description";"";"--”";"Référentiel seulement"
|
||||
"annee_creation";"";"--”";"Référentiel seulement"
|
||||
"type_investissement";"";"--”";"Référentiel seulement"
|
||||
"secteur";"";"--”";"Référentiel seulement (remplacé par categories_inv/secteurs_inv côté user)"
|
||||
"investisseurs_types";"";"--”";"Référentiel 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";"";"--”";"Référentiel seulement"
|
||||
"numero_licence";"";"--”";"Référentiel seulement"
|
||||
"is_regule";"";"--”";"Référentiel seulement"
|
||||
"pays_inscription";"";"--”";"Référentiel seulement"
|
||||
"pays_siege";"";"--”";"Référentiel seulement"
|
||||
"pays_operation";"";"--”";"Référentiel seulement"
|
||||
"investissement_minimum";"";"--”";"Référentiel seulement"
|
||||
"rendement_annonce";"";"--”";"Référentiel seulement"
|
||||
"nb_investisseurs";"";"--”";"Référentiel seulement"
|
||||
"volume_total_finance";"";"--”";"Référentiel seulement"
|
||||
"duree_moyenne_pret";"";"--”";"Référentiel seulement"
|
||||
"garantie_rachat";"";"--”";"Référentiel seulement"
|
||||
"statistiques_publiques";"";"--”";"Référentiel seulement"
|
||||
"bonus_inscription";"";"--”";"Référentiel seulement"
|
||||
"marche_secondaire";"";"--”";"Référentiel seulement"
|
||||
"investissement_auto";"";"--”";"Référentiel seulement"
|
||||
"url_trustpilot";"";"--”";"Référentiel seulement"
|
||||
"url_linkedin";"";"--”";"Référentiel seulement"
|
||||
"created_at";"created_at";"Non";"Présent dans les deux; timestamps indépendants"
|
||||
"updated_at";"";"--”";"Référentiel 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 --” détenteur"
|
||||
"";"date_ouverture";"--”";"Plateformes seulement"
|
||||
"";"type_pret_defaut";"--”";"Plateformes seulement --” valeur par défaut formulaire"
|
||||
"";"duree_defaut";"--”";"Plateformes seulement --” valeur par défaut formulaire"
|
||||
"";"taux_defaut";"--”";"Plateformes seulement --” valeur par défaut formulaire"
|
||||
"";"freq_interets_defaut";"--”";"Plateformes seulement --” valeur par défaut formulaire"
|
||||
"";"referentiel_id";"--”";"Plateformes seulement --” lien vers le référentiel"
|
||||
"";"overridden_fields";"--”";"Plateformes seulement --” champs surchargés (JSON)"
|
||||
|
@@ -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"
|
||||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 251 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
@@ -0,0 +1,168 @@
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="100%" viewBox="0 0 280 280" xml:space="preserve">
|
||||
|
||||
<path fill="#020202" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M30.741039,185.264648
|
||||
C34.850754,185.803101 38.795074,185.808823 42.211552,187.154587
|
||||
C55.504292,192.390656 68.629524,198.052032 82.220329,203.723434
|
||||
C82.684677,202.464737 83.058708,201.456894 83.428505,200.447510
|
||||
C86.189812,192.910324 91.634506,188.888397 99.662781,188.769638
|
||||
C108.323891,188.641495 117.023438,189.181534 125.637436,188.526093
|
||||
C130.777344,188.134995 136.576035,187.190735 140.701950,184.434464
|
||||
C152.451782,176.585068 164.784210,175.194321 177.916473,178.817169
|
||||
C187.527069,181.468491 196.994568,184.659164 206.455078,187.821152
|
||||
C209.214432,188.743408 211.124695,188.884689 213.472260,186.563660
|
||||
C217.931168,182.155167 223.705475,180.987579 229.712845,182.400909
|
||||
C236.673828,184.038574 243.584000,185.956985 250.407013,188.101089
|
||||
C260.719818,191.341843 265.738129,200.214874 263.043915,210.755112
|
||||
C258.675995,227.843079 254.146561,244.893127 249.405975,261.881256
|
||||
C246.299957,273.011688 237.008667,278.162903 225.854843,275.354431
|
||||
C219.726410,273.811340 213.638901,272.105530 207.534149,270.468597
|
||||
C199.234909,268.243256 194.736832,262.703430 193.062439,254.403732
|
||||
C192.743896,252.824677 191.224365,250.479080 189.966309,250.272919
|
||||
C184.502289,249.377487 178.667526,247.855011 173.447983,248.918533
|
||||
C159.771210,251.705261 146.286163,255.514435 132.836960,259.313507
|
||||
C119.578697,263.058624 107.604263,261.610046 95.653351,254.122040
|
||||
C73.246925,240.083008 50.295322,226.912796 27.539207,213.434006
|
||||
C21.859602,210.069885 18.689386,205.251816 19.251245,198.640137
|
||||
C19.830942,191.818512 23.774086,187.391266 30.741039,185.264648
|
||||
M204.015930,260.493225
|
||||
C204.735748,259.531433 205.824509,258.670624 206.119553,257.592590
|
||||
C210.023743,243.326599 213.829041,229.033447 217.631973,214.739899
|
||||
C219.836624,206.453613 221.992661,198.154404 224.262222,189.512054
|
||||
C218.335815,190.465820 215.656876,193.898468 214.355942,198.771057
|
||||
C209.939346,215.313370 205.561081,231.866196 201.055847,248.384369
|
||||
C199.862259,252.760635 200.378448,256.593109 204.015930,260.493225
|
||||
z"/>
|
||||
<path fill="#030303" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M184.061554,104.976257
|
||||
C196.670700,127.581947 196.929840,150.149643 186.534058,172.663910
|
||||
C178.677460,171.627686 171.489990,170.322342 164.246674,169.816330
|
||||
C155.522232,169.206909 147.416595,171.691910 140.190964,176.658127
|
||||
C135.060196,180.184525 129.516800,181.826736 123.272568,181.622238
|
||||
C116.947968,181.415131 110.594147,181.881088 104.283264,181.522217
|
||||
C93.295700,180.897430 84.220734,183.965714 77.745789,194.889282
|
||||
C63.455185,183.108444 55.349525,168.392929 53.272125,150.915024
|
||||
C49.481022,119.019058 62.587296,95.029556 89.666908,78.312355
|
||||
C84.673264,68.287727 86.589920,62.904018 97.325500,57.552887
|
||||
C93.867661,49.334270 90.309608,41.129490 86.960716,32.840210
|
||||
C84.142548,25.864597 87.627052,19.065536 94.727562,17.243216
|
||||
C97.702744,16.479645 99.533569,16.851942 100.459206,20.515516
|
||||
C103.351494,31.962927 106.644287,43.310944 109.923050,54.656681
|
||||
C110.342163,56.106995 111.596893,57.315823 112.464516,58.636524
|
||||
C112.979149,58.349861 113.493774,58.063198 114.008408,57.776531
|
||||
C110.492859,44.966797 106.977310,32.157066 103.172386,18.292906
|
||||
C105.355843,19.728430 106.529701,20.500189 107.301918,21.007887
|
||||
C108.915421,17.236635 109.670876,12.546047 112.406769,9.914133
|
||||
C115.044754,7.376392 119.724693,5.859782 123.512474,5.850252
|
||||
C131.171387,5.830983 134.524292,10.988303 135.861801,22.836306
|
||||
C137.443314,21.585693 138.920975,20.417219 141.580307,18.314306
|
||||
C137.764938,32.282726 134.306259,44.945282 130.847565,57.607838
|
||||
C131.301468,57.908848 131.755371,58.209858 132.209274,58.510868
|
||||
C133.160736,57.174480 134.562424,55.967972 134.991394,54.480869
|
||||
C138.034042,43.933170 140.874054,33.327072 143.788147,22.742205
|
||||
C144.221542,21.167969 144.704803,19.607462 145.133484,18.147886
|
||||
C155.446060,18.035545 160.584763,25.267275 156.849686,34.343960
|
||||
C153.686447,42.031002 150.342773,49.643787 147.398651,56.540470
|
||||
C150.597595,59.738964 154.599625,62.193951 156.134125,65.722137
|
||||
C157.585526,69.059242 156.445953,73.523224 156.445953,78.462440
|
||||
C167.195969,84.041451 176.763107,93.062119 184.061554,104.976257
|
||||
M144.081635,167.614914
|
||||
C146.315521,165.971283 149.556427,164.564896 147.422470,161.032028
|
||||
C145.240662,157.419937 142.667206,159.642563 140.219818,161.133530
|
||||
C134.645599,164.529388 128.550430,164.997162 122.361633,163.627014
|
||||
C113.761368,161.723007 106.072411,153.783752 105.238274,146.211151
|
||||
C106.578323,146.211151 107.900383,146.210968 109.222443,146.211182
|
||||
C116.386147,146.212326 123.561325,146.426620 130.704208,146.041534
|
||||
C132.197815,145.961029 133.572220,143.669540 135.001556,142.396912
|
||||
C133.606781,141.237671 132.364532,139.731293 130.768539,139.029984
|
||||
C129.511520,138.477631 127.818481,138.916901 126.319107,138.916855
|
||||
C118.548454,138.916626 110.777809,138.916748 103.117775,138.916748
|
||||
C103.117775,136.544952 103.117775,134.798645 103.117775,132.785446
|
||||
C105.596176,132.785446 107.740196,132.786102 109.884224,132.785324
|
||||
C117.214523,132.782654 124.544907,132.758087 131.875076,132.787796
|
||||
C134.539215,132.798584 137.280762,132.577301 137.371689,129.225174
|
||||
C137.474640,125.429893 134.475601,125.398415 131.679443,125.428108
|
||||
C128.681000,125.459953 125.681999,125.438591 122.683235,125.438911
|
||||
C116.804970,125.439537 110.926712,125.439110 104.490334,125.439110
|
||||
C107.500740,117.347153 112.184036,112.002823 119.353844,109.139534
|
||||
C126.972710,106.096916 134.292145,106.985687 141.339844,111.263184
|
||||
C142.666626,112.068459 144.832016,112.239861 146.308701,111.765137
|
||||
C147.171280,111.487839 148.177139,109.052437 147.801407,108.116982
|
||||
C147.186417,106.585869 145.700974,105.152817 144.230530,104.255424
|
||||
C127.861961,94.265968 106.406509,101.499138 98.506859,119.939514
|
||||
C96.882896,123.730392 95.519562,126.046600 90.952126,125.684250
|
||||
C89.770241,125.590485 88.392242,127.968536 87.104332,129.211166
|
||||
C88.468910,130.401672 89.698029,131.836731 91.246834,132.694702
|
||||
C92.240128,133.244934 93.704613,132.944550 94.809586,133.015900
|
||||
C94.809586,135.198212 94.809586,136.959381 94.809586,138.737442
|
||||
C94.064148,138.815262 93.580261,138.920685 93.099815,138.906860
|
||||
C90.288078,138.826050 87.234627,138.979675 87.210983,142.591232
|
||||
C87.186844,146.276749 90.307976,146.572998 93.057350,146.143768
|
||||
C96.385231,145.624207 96.746338,147.922119 97.629951,150.079391
|
||||
C105.645470,169.648544 125.000000,177.195023 144.081635,167.614914
|
||||
M146.774307,74.868759
|
||||
C149.606537,73.624908 151.360046,71.242615 149.843201,68.579979
|
||||
C148.867905,66.867989 146.020111,65.303169 143.981186,65.264191
|
||||
C129.521393,64.987778 115.053589,65.159294 100.588692,65.100632
|
||||
C97.010948,65.086121 94.018715,65.953148 94.004646,70.056488
|
||||
C93.990746,74.109421 96.893318,75.141106 100.511284,75.123749
|
||||
C115.640556,75.051147 130.770248,75.065552 146.774307,74.868759
|
||||
z"/>
|
||||
<path fill="#F4F4F4" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M203.742249,260.239441
|
||||
C200.378448,256.593109 199.862259,252.760635 201.055847,248.384369
|
||||
C205.561081,231.866196 209.939346,215.313370 214.355942,198.771057
|
||||
C215.656876,193.898468 218.335815,190.465820 224.262222,189.512054
|
||||
C221.992661,198.154404 219.836624,206.453613 217.631973,214.739899
|
||||
C213.829041,229.033447 210.023743,243.326599 206.119553,257.592590
|
||||
C205.824509,258.670624 204.735748,259.531433 203.742249,260.239441
|
||||
z"/>
|
||||
<path fill="#F4F4F4" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M143.749771,167.797684
|
||||
C125.000000,177.195023 105.645470,169.648544 97.629951,150.079391
|
||||
C96.746338,147.922119 96.385231,145.624207 93.057350,146.143768
|
||||
C90.307976,146.572998 87.186844,146.276749 87.210983,142.591232
|
||||
C87.234627,138.979675 90.288078,138.826050 93.099815,138.906860
|
||||
C93.580261,138.920685 94.064148,138.815262 94.809586,138.737442
|
||||
C94.809586,136.959381 94.809586,135.198212 94.809586,133.015900
|
||||
C93.704613,132.944550 92.240128,133.244934 91.246834,132.694702
|
||||
C89.698029,131.836731 88.468910,130.401672 87.104332,129.211166
|
||||
C88.392242,127.968536 89.770241,125.590485 90.952126,125.684250
|
||||
C95.519562,126.046600 96.882896,123.730392 98.506859,119.939514
|
||||
C106.406509,101.499138 127.861961,94.265968 144.230530,104.255424
|
||||
C145.700974,105.152817 147.186417,106.585869 147.801407,108.116982
|
||||
C148.177139,109.052437 147.171280,111.487839 146.308701,111.765137
|
||||
C144.832016,112.239861 142.666626,112.068459 141.339844,111.263184
|
||||
C134.292145,106.985687 126.972710,106.096916 119.353844,109.139534
|
||||
C112.184036,112.002823 107.500740,117.347153 104.490334,125.439110
|
||||
C110.926712,125.439110 116.804970,125.439537 122.683235,125.438911
|
||||
C125.681999,125.438591 128.681000,125.459953 131.679443,125.428108
|
||||
C134.475601,125.398415 137.474640,125.429893 137.371689,129.225174
|
||||
C137.280762,132.577301 134.539215,132.798584 131.875076,132.787796
|
||||
C124.544907,132.758087 117.214523,132.782654 109.884224,132.785324
|
||||
C107.740196,132.786102 105.596176,132.785446 103.117775,132.785446
|
||||
C103.117775,134.798645 103.117775,136.544952 103.117775,138.916748
|
||||
C110.777809,138.916748 118.548454,138.916626 126.319107,138.916855
|
||||
C127.818481,138.916901 129.511520,138.477631 130.768539,139.029984
|
||||
C132.364532,139.731293 133.606781,141.237671 135.001556,142.396912
|
||||
C133.572220,143.669540 132.197815,145.961029 130.704208,146.041534
|
||||
C123.561325,146.426620 116.386147,146.212326 109.222443,146.211182
|
||||
C107.900383,146.210968 106.578323,146.211151 105.238274,146.211151
|
||||
C106.072411,153.783752 113.761368,161.723007 122.361633,163.627014
|
||||
C128.550430,164.997162 134.645599,164.529388 140.219818,161.133530
|
||||
C142.667206,159.642563 145.240662,157.419937 147.422470,161.032028
|
||||
C149.556427,164.564896 146.315521,165.971283 143.749771,167.797684
|
||||
z"/>
|
||||
<path fill="#F8F8F8" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M146.337051,74.958412
|
||||
C130.770248,75.065552 115.640556,75.051147 100.511284,75.123749
|
||||
C96.893318,75.141106 93.990746,74.109421 94.004646,70.056488
|
||||
C94.018715,65.953148 97.010948,65.086121 100.588692,65.100632
|
||||
C115.053589,65.159294 129.521393,64.987778 143.981186,65.264191
|
||||
C146.020111,65.30316
|
||||
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
@@ -0,0 +1,348 @@
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="100%" viewBox="0 0 809 731" enable-background="new 0 0 809 731" xml:space="preserve">
|
||||
<path fill="#FDF9FA" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M468.000000,732.000000
|
||||
C312.000000,732.000000 156.500000,732.000000 1.000000,732.000000
|
||||
C1.000000,488.333344 1.000000,244.666672 1.000000,1.000000
|
||||
C270.666656,1.000000 540.333313,1.000000 810.000000,1.000000
|
||||
C810.000000,244.666672 810.000000,488.333344 810.000000,732.000000
|
||||
C696.166687,732.000000 582.333313,732.000000 468.000000,732.000000
|
||||
M285.500153,136.780136
|
||||
C224.506607,136.794495 163.513000,136.763977 102.519547,136.844589
|
||||
C76.975227,136.878342 59.562992,154.279449 59.545284,179.902847
|
||||
C59.473412,283.893433 59.457268,387.884125 59.525185,491.874695
|
||||
C59.541706,517.175049 77.464554,534.884827 102.718513,534.892456
|
||||
C186.544403,534.917603 270.370300,534.902344 354.196198,534.902710
|
||||
C355.802246,534.902710 357.408295,534.902771 359.226135,534.902771
|
||||
C359.226135,532.456177 359.225372,530.631775 359.226257,528.807312
|
||||
C359.245209,488.810883 359.209869,448.814362 359.308105,408.818115
|
||||
C359.352356,390.792816 367.183289,377.002533 383.080963,368.437683
|
||||
C388.212891,365.672882 394.239899,364.569489 400.526672,362.484100
|
||||
C400.526672,355.047882 400.420532,346.913452 400.557068,338.783081
|
||||
C400.688324,330.964447 400.115997,322.973969 401.518829,315.366241
|
||||
C406.026123,290.923309 426.693176,274.426636 452.131989,274.372345
|
||||
C496.127869,274.278473 540.124084,274.340302 584.120178,274.336823
|
||||
C585.888062,274.336700 587.655945,274.336792 589.600647,274.336792
|
||||
C589.600647,256.426849 589.642151,239.129898 589.547424,221.833694
|
||||
C589.535767,219.707504 588.925110,217.551193 588.373718,215.466599
|
||||
C583.352783,196.486160 568.459351,185.120483 548.363586,185.115601
|
||||
C422.541412,185.084976 296.719238,185.094009 170.897064,185.093155
|
||||
C149.065689,185.093002 127.234192,185.148727 105.403000,185.088333
|
||||
C99.850014,185.072968 95.764297,181.298767 95.198006,176.087875
|
||||
C94.655792,171.098587 98.247612,166.460007 103.770897,165.577591
|
||||
C106.703072,165.109116 109.747086,165.267227 112.740631,165.266861
|
||||
C249.728455,165.250320 386.716339,165.196167 523.704041,165.333633
|
||||
C535.590271,165.345566 547.475220,166.631958 559.510986,167.335129
|
||||
C561.903503,152.870331 552.031982,136.445541 531.977112,136.569519
|
||||
C450.154144,137.075348 368.326263,136.781219 285.500153,136.780136
|
||||
M472.500153,376.744812
|
||||
C450.008209,376.739777 427.516235,376.767456 405.024384,376.719818
|
||||
C385.373322,376.678192 372.381622,389.259979 372.338013,409.074646
|
||||
C372.215881,464.554291 372.232666,520.034485 372.329193,575.514221
|
||||
C372.362000,594.364014 385.377167,607.161865 404.150513,607.163818
|
||||
C518.775757,607.175842 633.400940,607.172791 748.026184,607.154297
|
||||
C767.657349,607.151123 780.372925,594.735413 780.408936,575.116455
|
||||
C780.510803,519.636841 780.506104,464.156799 780.386780,408.677246
|
||||
C780.344788,389.161865 767.504639,376.746704 747.900940,376.745697
|
||||
C656.434021,376.740906 564.967102,376.743744 472.500153,376.744812
|
||||
M413.501617,335.528687
|
||||
C413.501617,344.780029 413.501617,354.031372 413.501617,363.205017
|
||||
C481.445892,363.205017 548.665161,363.205017 616.174866,363.205017
|
||||
C616.174866,343.398560 616.169312,323.919922 616.176880,304.441315
|
||||
C616.181702,292.033630 611.809753,287.631989 599.399658,287.625916
|
||||
C549.920044,287.601807 500.440399,287.581665 450.960754,287.566101
|
||||
C434.239044,287.560852 419.307861,298.465637 415.170563,314.743073
|
||||
C413.561188,321.074860 413.996704,327.926422 413.501617,335.528687
|
||||
z"/>
|
||||
<path fill="#010101" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M286.000183,136.780975
|
||||
C368.326263,136.781219 450.154144,137.075348 531.977112,136.569519
|
||||
C552.031982,136.445541 561.903503,152.870331 559.510986,167.335129
|
||||
C547.475220,166.631958 535.590271,165.345566 523.704041,165.333633
|
||||
C386.716339,165.196167 249.728455,165.250320 112.740631,165.266861
|
||||
C109.747086,165.267227 106.703072,165.109116 103.770897,165.577591
|
||||
C98.247612,166.460007 94.655792,171.098587 95.198006,176.087875
|
||||
C95.764297,181.298767 99.850014,185.072968 105.403000,185.088333
|
||||
C127.234192,185.148727 149.065689,185.093002 170.897064,185.093155
|
||||
C296.719238,185.094009 422.541412,185.084976 548.363586,185.115601
|
||||
C568.459351,185.120483 583.352783,196.486160 588.373718,215.466599
|
||||
C588.925110,217.551193 589.535767,219.707504 589.547424,221.833694
|
||||
C589.642151,239.129898 589.600647,256.426849 589.600647,274.336792
|
||||
C587.655945,274.336792 585.888062,274.336700 584.120178,274.336823
|
||||
C540.124084,274.340302 496.127869,274.278473 452.131989,274.372345
|
||||
C426.693176,274.426636 406.026123,290.923309 401.518829,315.366241
|
||||
C400.115997,322.973969 400.688324,330.964447 400.557068,338.783081
|
||||
C400.420532,346.913452 400.526672,355.047882 400.526672,362.484100
|
||||
C394.239899,364.569489 388.212891,365.672882 383.080963,368.437683
|
||||
C367.183289,377.002533 359.352356,390.792816 359.308105,408.818115
|
||||
C359.209869,448.814362 359.245209,488.810883 359.226257,528.807312
|
||||
C359.225372,530.631775 359.226135,532.456177 359.226135,534.902771
|
||||
C357.408295,534.902771 355.802246,534.902710 354.196198,534.902710
|
||||
C270.370300,534.902344 186.544403,534.917603 102.718513,534.892456
|
||||
C77.464554,534.884827 59.541706,517.175049 59.525185,491.874695
|
||||
C59.457268,387.884125 59.473412,283.893433 59.545284,179.902847
|
||||
C59.562992,154.279449 76.975227,136.878342 102.519547,136.844589
|
||||
C163.513000,136.763977 224.506607,136.794495 286.000183,136.780975
|
||||
z"/>
|
||||
<path fill="#020202" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M473.000183,376.744354
|
||||
C564.967102,376.743744 656.434021,376.740906 747.900940,376.745697
|
||||
C767.504639,376.746704 780.344788,389.161865 780.386780,408.677246
|
||||
C780.506104,464.156799 780.510803,519.636841 780.408936,575.116455
|
||||
C780.372925,594.735413 767.657349,607.151123 748.026184,607.154297
|
||||
C633.400940,607.172791 518.775757,607.175842 404.150513,607.163818
|
||||
C385.377167,607.161865 372.362000,594.364014 372.329193,575.514221
|
||||
C372.232666,520.034485 372.215881,464.554291 372.338013,409.074646
|
||||
C372.381622,389.259979 385.373322,376.678192 405.024384,376.719818
|
||||
C427.516235,376.767456 450.008209,376.739777 473.000183,376.744354
|
||||
M445.238922,554.497864
|
||||
C445.252228,545.670532 445.343536,536.842468 445.251434,528.016235
|
||||
C445.190186,522.146362 442.530304,519.490417 436.660217,519.419006
|
||||
C429.166809,519.327881 421.669159,519.346619 414.176727,519.493103
|
||||
C408.443420,519.605103 405.912842,522.107056 405.870148,527.749146
|
||||
C405.769379,541.072693 405.752716,554.397583 405.826813,567.721252
|
||||
C405.855591,572.896423 408.288544,575.587524 413.351379,575.716858
|
||||
C421.506805,575.925354 429.676208,575.919373 437.831055,575.692627
|
||||
C442.580627,575.560608 444.975891,572.861267 445.164307,567.985901
|
||||
C445.324982,563.828003 445.223145,559.659912 445.238922,554.497864
|
||||
M721.491455,575.716675
|
||||
C726.095215,575.716675 730.698975,575.716675 735.456543,575.716675
|
||||
C735.456543,572.552979 735.456543,569.972351 735.456543,567.208984
|
||||
C663.387024,567.208984 591.578735,567.208984 519.600647,567.208984
|
||||
C519.600647,570.079041 519.600647,572.654785 519.600647,575.715698
|
||||
C586.739258,575.715698 653.616638,575.715698 721.491455,575.716675
|
||||
M698.500305,512.596008
|
||||
C638.955322,512.596008 579.410278,512.596008 519.684204,512.596008
|
||||
C519.684204,515.758240 519.684204,518.329468 519.684204,520.859741
|
||||
C591.805481,520.859741 663.616577,520.859741 735.505920,520.859741
|
||||
C735.505920,518.010437 735.505920,515.576294 735.505920,512.596497
|
||||
C723.360352,512.596497 711.430359,512.596497 698.500305,512.596008
|
||||
M592.500061,530.832642
|
||||
C568.253052,530.832642 544.005981,530.832642 519.747009,530.832642
|
||||
C519.747009,534.028625 519.747009,536.574890 519.747009,539.244873
|
||||
C591.832458,539.244873 663.550476,539.244873 735.514954,539.244873
|
||||
C735.514954,536.426819 735.514954,533.858215 735.514954,530.832642
|
||||
C688.008301,530.832642 640.754211,530.832642 592.500061,530.832642
|
||||
M654.500000,407.129791
|
||||
C645.746338,407.129791 636.992676,407.129791 628.324707,407.129791
|
||||
C628.324707,410.389496 628.324707,412.957642 628.324707,415.583984
|
||||
C664.188354,415.583984 699.707764,415.583984 735.468872,415.583984
|
||||
C735.468872,412.735962 735.468872,410.166351 735.468872,407.129120
|
||||
C708.637512,407.129120 682.068787,407.129120 654.500000,407.129791
|
||||
M434.871552,427.481262
|
||||
C438.234924,424.701721 439.854767,421.315948 438.499451,416.922546
|
||||
C437.185394,412.662811 433.784393,410.803772 429.819061,410.582275
|
||||
C423.434692,410.225616 417.015808,410.486542 410.546631,410.486542
|
||||
C410.546631,422.690887 410.546631,434.583374 410.546631,446.443481
|
||||
C417.624481,446.443481 424.411011,446.689392 431.169281,446.358093
|
||||
C435.772522,446.132446 439.526886,444.091461 440.469147,438.926758
|
||||
C441.353455,434.079834 439.525269,430.511749 434.871552,427.481262
|
||||
M492.717804,436.455322
|
||||
C492.717804,431.072998 492.717804,425.690674 492.717804,418.968628
|
||||
C499.960419,428.103546 506.224243,436.193634 512.748108,444.068329
|
||||
C514.015198,445.597809 516.295654,446.287781 518.681519,447.705353
|
||||
C518.681519,434.239288 518.681519,422.346161 518.681519,410.129791
|
||||
C516.876160,410.290039 515.484924,410.413544 513.693542,410.572571
|
||||
C513.693542,419.455353 513.693542,427.980225 513.693542,437.498596
|
||||
C506.766571,428.754578 500.589966,420.764404 494.151642,412.991089
|
||||
C492.836975,411.403900 490.539276,410.630920 487.933472,409.008698
|
||||
C487.933472,422.544983 487.933472,434.469818 487.933472,446.588959
|
||||
C489.556244,446.588959 490.927032,446.588959 492.720337,446.588959
|
||||
C492.720337,443.353668 492.720337,440.393311 492.717804,436.455322
|
||||
M555.468262,413.918762
|
||||
C556.312927,412.978699 557.157654,412.038666 558.369507,410.690033
|
||||
C551.022400,409.211487 549.031738,415.374908 545.256042,418.463623
|
||||
C541.272827,421.722076 537.586548,425.343536 533.381226,429.159729
|
||||
C533.381226,422.798462 533.381226,416.700256 533.381226,410.506714
|
||||
C531.592957,410.506714 530.321838,410.506714 528.894287,410.506714
|
||||
C528.894287,422.627441 528.894287,434.528839 528.894287,446.800446
|
||||
C530.592285,446.678192 531.968262,446.579163 533.793823,446.447723
|
||||
C532.338684,439.351837 534.470459,434.029572 540.550598,430.181915
|
||||
C544.108826,434.690613 547.785217,439.155731 551.233521,443.790527
|
||||
C553.326416,446.603394 555.620483,447.929138 559.405884,446.174438
|
||||
C554.114502,439.547943 548.960449,433.093445 543.657959,426.453064
|
||||
C547.474060,422.408417 551.229309,418.428314 555.468262,413.918762
|
||||
M452.953644,438.923584
|
||||
C457.240692,438.451447 461.553650,438.142181 465.807922,437.464264
|
||||
C470.674133,436.688782 473.748077,438.301453 474.919678,443.227997
|
||||
C475.742493,446.687958 477.976776,447.606689 481.553497,446.380188
|
||||
C480.615326,444.256836 479.693420,442.158661 478.762115,440.064636
|
||||
C474.793579,431.141449 470.867004,422.199158 466.830292,413.306946
|
||||
C465.175537,409.661743 461.124268,408.765198 459.715149,411.823669
|
||||
C454.474426,423.198975 449.576813,434.732391 444.500458,446.320404
|
||||
C451.287445,448.365723 450.521698,442.439941 452.953644,438.923584
|
||||
z"/>
|
||||
<path fill="#020202" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M413.501190,335.034943
|
||||
C413.996704,327.926422 413.561188,321.074860 415.170563,314.743073
|
||||
C419.307861,298.465637 434.239044,287.560852 450.960754,287.566101
|
||||
C500.440399,287.581665 549.920044,287.601807 599.399658,287.625916
|
||||
C611.809753,287.631989 616.181702,292.033630 616.176880,304.441315
|
||||
C616.169312,323.919922 616.174866,343.398560 616.174866,363.205017
|
||||
C548.665161,363.205017 481.445892,363.205017 413.501617,363.205017
|
||||
C413.501617,354.031372 413.501617,344.780029 413.501190,335.034943
|
||||
M473.312531,313.938446
|
||||
C464.340332,312.422852 456.692719,315.993622 453.312653,323.276581
|
||||
C449.963379,330.493195 451.709259,339.223816 457.505951,344.245911
|
||||
C463.491547,349.431702 472.301697,350.050873 478.736877,345.519287
|
||||
C484.368378,341.553619 486.834930,336.010345 486.108521,329.124939
|
||||
C485.310211,321.558258 481.061066,316.729431 473.312531,313.938446
|
||||
z"/>
|
||||
<path fill="#FBF7F8" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M445.238159,554.997070
|
||||
C445.223145,559.659912 445.324982,563.828003 445.164307,567.985901
|
||||
C444.975891,572.861267 442.580627,575.560608 437.831055,575.692627
|
||||
C429.676208,575.919373 421.506805,575.925354 413.351379,575.716858
|
||||
C408.288544,575.587524 405.855591,572.896423 405.826813,567.721252
|
||||
C405.752716,554.397583 405.769379,541.072693 405.870148,527.749146
|
||||
C405.912842,522.107056 408.443420,519.605103 414.176727,519.493103
|
||||
C421.669159,519.346619 429.166809,519.327881 436.660217,519.419006
|
||||
C442.530304,519.490417 445.190186,522.146362 445.251434,528.016235
|
||||
C445.343536,536.842468 445.252228,545.670532 445.238159,554.997070
|
||||
z"/>
|
||||
<path fill="#F2EFF0" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M720.992737,575.716187
|
||||
C653.616638,575.715698 586.739258,575.715698 519.600647,575.715698
|
||||
C519.600647,572.654785 519.600647,570.079041 519.600647,567.208984
|
||||
C591.578735,567.208984 663.387024,567.208984 735.456543,567.208984
|
||||
C735.456543,569.972351 735.456543,572.552979 735.456543,575.716675
|
||||
C730.698975,575.716675 726.095215,575.716675 720.992737,575.716187
|
||||
z"/>
|
||||
<path fill="#FDF9FA" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M699.000366,512.596252
|
||||
C711.430359,512.596497 723.360352,512.596497 735.505920,512.596497
|
||||
C735.505920,515.576294 735.505920,518.010437 735.505920,520.859741
|
||||
C663.616577,520.859741 591.805481,520.859741 519.684204,520.859741
|
||||
C519.684204,518.329468 519.684204,515.758240 519.684204,512.596008
|
||||
C579.410278,512.596008 638.955322,512.596008 699.000366,512.596252
|
||||
z"/>
|
||||
<path fill="#FDF9FA" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M593.000122,530.832642
|
||||
C640.754211,530.832642 688.008301,530.832642 735.514954,530.832642
|
||||
C735.514954,533.858215 735.514954,536.426819 735.514954,539.244873
|
||||
C663.550476,539.244873 591.832458,539.244873 519.747009,539.244873
|
||||
C519.747009,536.574890 519.747009,534.028625 519.747009,530.832642
|
||||
C544.005981,530.832642 568.253052,530.832642 593.000122,530.832642
|
||||
z"/>
|
||||
<path fill="#EEEBEC" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M655.000000,407.129456
|
||||
C682.068787,407.129120 708.637512,407.129120 735.468872,407.129120
|
||||
C735.468872,410.166351 735.468872,412.735962 735.468872,415.583984
|
||||
C699.707764,415.583984 664.188354,415.583984 628.324707,415.583984
|
||||
C628.324707,412.957642 628.324707,410.389496 628.324707,407.129791
|
||||
C636.992676,407.129791 645.746338,407.129791 655.000000,407.129456
|
||||
z"/>
|
||||
<path fill="#ECE9E9" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M435.093719,427.780579
|
||||
C439.525269,430.511749 441.353455,434.079834 440.469147,438.926758
|
||||
C439.526886,444.091461 435.772522,446.132446 431.169281,446.358093
|
||||
C424.411011,446.689392 417.624481,446.443481 410.546631,446.443481
|
||||
C410.546631,434.583374 410.546631,422.690887 410.546631,410.486542
|
||||
C417.015808,410.486542 423.434692,410.225616 429.819061,410.582275
|
||||
C433.784393,410.803772 437.185394,412.662811 438.499451,416.922546
|
||||
C439.854767,421.315948 438.234924,424.701721 435.093719,427.780579
|
||||
M415.215546,431.891083
|
||||
C415.215546,435.323547 415.215546,438.756012 415.215546,442.113464
|
||||
C420.698730,442.113464 425.716736,442.600739 430.551178,441.871429
|
||||
C432.460449,441.583405 435.355408,438.535767 435.294556,436.834320
|
||||
C435.218506,434.706421 432.770966,431.134674 430.975311,430.887512
|
||||
C425.878693,430.185944 420.592896,430.858978 415.215546,431.891083
|
||||
M432.946869,423.425323
|
||||
C434.708313,418.166840 433.267334,415.205383 428.048492,414.639832
|
||||
C423.879059,414.187988 419.621552,414.548615 415.478333,414.548615
|
||||
C415.478333,418.618042 415.478333,422.182312 415.478333,427.030182
|
||||
C421.446075,425.996857 426.962067,425.041748 432.946869,423.425323
|
||||
z"/>
|
||||
<path fill="#F1EEEF" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M492.719055,436.944122
|
||||
C492.720337,440.393311 492.720337,443.353668 492.720337,446.588959
|
||||
C490.927032,446.588959 489.556244,446.588959 487.933472,446.588959
|
||||
C487.933472,434.469818 487.933472,422.544983 487.933472,409.008698
|
||||
C490.539276,410.630920 492.836975,411.403900 494.151642,412.991089
|
||||
C500.589966,420.764404 506.766571,428.754578 513.693542,437.498596
|
||||
C513.693542,427.980225 513.693542,419.455353 513.693542,410.572571
|
||||
C515.484924,410.413544 516.876160,410.290039 518.681519,410.129791
|
||||
C518.681519,422.346161 518.681519,434.239288 518.681519,447.705353
|
||||
C516.295654,446.287781 514.015198,445.597809 512.748108,444.068329
|
||||
C506.224243,436.193634 499.960419,428.103546 492.717804,418.968628
|
||||
C492.717804,425.690674 492.717804,431.072998 492.719055,436.944122
|
||||
z"/>
|
||||
<path fill="#F0EDEE" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M555.226379,414.183502
|
||||
C551.229309,418.428314 547.474060,422.408417 543.657959,426.453064
|
||||
C548.960449,433.093445 554.114502,439.547943 559.405884,446.174438
|
||||
C555.620483,447.929138 553.326416,446.603394 551.233521,443.790527
|
||||
C547.785217,439.155731 544.108826,434.690613 540.550598,430.181915
|
||||
C534.470459,434.029572 532.338684,439.351837 533.793823,446.447723
|
||||
C531.968262,446.579163 530.592285,446.678192 528.894287,446.800446
|
||||
C528.894287,434.528839 528.894287,422.627441 528.894287,410.506714
|
||||
C530.321838,410.506714 531.592957,410.506714 533.381226,410.506714
|
||||
C533.381226,416.700256 533.381226,422.798462 533.381226,429.159729
|
||||
C537.586548,425.343536 541.272827,421.722076 545.256042,418.463623
|
||||
C549.031738,415.374908 551.022400,409.211487 558.369507,410.690033
|
||||
C557.157654,412.038666 556.312927,412.978699 555.226379,414.183502
|
||||
z"/>
|
||||
<path fill="#ECE9EA" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M452.764404,439.299866
|
||||
C450.521698,442.439941 451.287445,448.365723 444.500458,446.320404
|
||||
C449.576813,434.732391 454.474426,423.198975 459.715149,411.823669
|
||||
C461.124268,408.765198 465.175537,409.661743 466.830292,413.306946
|
||||
C470.867004,422.199158 474.793579,431.141449 478.762115,440.064636
|
||||
C479.693420,442.158661 480.615326,444.256836 481.553497,446.380188
|
||||
C477.976776,447.606689 475.742493,446.687958 474.919678,443.227997
|
||||
C473.748077,438.301453 470.674133,436.688782 465.807922,437.464264
|
||||
C461.553650,438.142181 457.240692,438.451447 452.764404,439.299866
|
||||
M459.918427,422.399170
|
||||
C458.496094,425.837799 457.073761,429.276398 455.533142,433.000916
|
||||
C460.730316,433.000916 465.267914,433.000916 470.375763,433.000916
|
||||
C467.914978,427.221497 465.636353,421.869934 462.981934,415.635742
|
||||
C461.757660,418.256836 460.963135,419.957886 459.918427,422.399170
|
||||
z"/>
|
||||
<path fill="#FBF7F8" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M473.695801,314.019897
|
||||
C481.061066,316.729431 485.310211,321.558258 486.108521,329.124939
|
||||
C486.834930,336.010345 484.368378,341.553619 478.736877,345.519287
|
||||
C472.301697,350.050873 463.491547,349.431702 457.505951,344.245911
|
||||
C451.709259,339.223816 449.963379,330.493195 453.312653,323.276581
|
||||
C456.692719,315.993622 464.340332,312.422852 473.695801,314.019897
|
||||
z"/>
|
||||
<path fill="#0F0F0F" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M415.298553,431.440918
|
||||
C420.592896,430.858978 425.878693,430.185944 430.975311,430.887512
|
||||
C432.770966,431.134674 435.218506,434.706421 435.294556,436.834320
|
||||
C435.355408,438.535767 432.460449,441.583405 430.551178,441.871429
|
||||
C425.716736,442.600739 420.698730,442.113464 415.215546,442.113464
|
||||
C415.215546,438.756012 415.215546,435.323547 415.298553,431.440918
|
||||
z"/>
|
||||
<path fill="#0C0C0C" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M432.712463,423.755981
|
||||
C426.962067,425.041748 421.446075,425.996857 415.478333,427.030182
|
||||
C415.478333,422.182312 415.478333,418.618042 415.478333,414.548615
|
||||
C419.621552,414.548615 423.879059,414.187988 428.048492,414.639832
|
||||
C433.267334,415.205383 434.708313,418.166840 432.712463,423.755981
|
||||
z"/>
|
||||
<path fill="#0A0A0A" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M460.043518,422.029053
|
||||
C460.963135,419.957886 461.757660,418.256836 462.981934,415.635742
|
||||
C465.636353,421.869934 467.914978,427.221497 470.375763,433.000916
|
||||
C465.267914,433.000916 460.730316,433.000916 455.533142,433.000916
|
||||
C457.073761,429.276398 458.496094,425.837799 460.043518,422.029053
|
||||
z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,346 @@
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="100%" viewBox="0 0 892 843" enable-background="new 0 0 892 843" xml:space="preserve">
|
||||
<path fill="#FEFEFE" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M507.000000,844.000000
|
||||
C338.000061,844.000000 169.500107,844.000000 1.000113,844.000000
|
||||
C1.000075,563.000122 1.000075,282.000275 1.000038,1.000301
|
||||
C298.333099,1.000201 595.666199,1.000201 892.999512,1.000100
|
||||
C892.999634,281.999695 892.999634,562.999390 892.999817,843.999512
|
||||
C764.500000,844.000000 636.000000,844.000000 507.000000,844.000000
|
||||
M542.320068,45.082878
|
||||
C515.930359,35.672890 488.869904,29.104166 461.046326,25.658094
|
||||
C449.166595,24.186737 443.754120,28.679417 443.740356,40.581329
|
||||
C443.706024,70.242226 443.701202,99.903152 443.699677,129.564072
|
||||
C443.699158,139.780212 447.254486,144.098541 457.342712,145.879486
|
||||
C505.690002,154.414627 548.050293,175.020309 584.447815,208.093491
|
||||
C614.186951,235.116486 636.365967,267.356171 650.598267,304.735016
|
||||
C656.947144,321.409485 660.788818,339.047943 665.587280,356.299255
|
||||
C667.737244,364.028534 671.709045,367.944519 679.009766,367.964020
|
||||
C710.336853,368.047638 741.664429,368.058624 772.991455,367.960571
|
||||
C781.559875,367.933777 787.311218,361.034821 785.806519,352.643524
|
||||
C783.404907,339.250671 781.135010,325.813751 778.095520,312.559113
|
||||
C762.028564,242.493698 727.989807,182.239914 677.282349,131.569260
|
||||
C638.871155,93.185966 593.817322,64.716614 542.320068,45.082878
|
||||
M249.879517,605.086487
|
||||
C227.172363,627.596802 204.354080,649.996704 181.821686,672.680603
|
||||
C174.489395,680.062134 175.512619,687.724487 184.565399,694.124084
|
||||
C197.317078,703.138489 210.020752,712.335022 223.453003,720.252441
|
||||
C273.782776,749.918274 328.047516,767.322266 386.505096,771.360840
|
||||
C437.099304,774.856140 486.434448,769.122314 534.334229,752.145264
|
||||
C537.711182,750.948364 541.017090,749.551392 544.615234,748.146301
|
||||
C525.559875,707.200806 522.778503,665.497559 535.285461,622.213013
|
||||
C533.660034,622.856995 532.731262,623.133911 531.886719,623.572754
|
||||
C506.343689,636.843872 479.588409,646.101440 450.935181,650.278381
|
||||
C416.649292,655.276428 382.871002,653.417175 349.452911,644.928711
|
||||
C319.340820,637.279968 291.761261,623.928833 266.346741,606.094482
|
||||
C261.315094,602.563538 256.151459,601.320129 249.879517,605.086487
|
||||
M62.871517,531.532166
|
||||
C76.299995,566.368469 94.358360,598.576965 117.540878,627.879395
|
||||
C123.330544,635.197449 131.488754,636.151611 137.827927,629.843628
|
||||
C159.317520,608.459473 180.752716,587.019531 202.062515,565.456421
|
||||
C209.367096,558.065002 209.328140,552.577698 203.858261,543.641602
|
||||
C196.147614,531.044678 188.299973,518.475525 181.543472,505.362518
|
||||
C168.295303,479.650391 161.543091,451.888702 158.376160,423.306793
|
||||
C155.306396,395.602020 157.795013,368.186310 164.548187,341.152130
|
||||
C167.348129,329.943451 165.037674,324.880554 154.806519,320.498779
|
||||
C127.635887,308.862122 100.405411,297.365204 73.238480,285.720001
|
||||
C62.688972,281.197906 55.975334,284.235382 52.943752,295.222534
|
||||
C40.704552,339.580200 35.525383,384.610504 39.789570,430.587708
|
||||
C42.982471,465.014069 50.058784,498.524048 62.871517,531.532166
|
||||
M215.677963,236.191559
|
||||
C235.537338,211.528305 259.291748,191.410980 286.676575,175.579193
|
||||
C312.691864,160.539154 340.658661,150.900879 370.203796,145.667511
|
||||
C379.309235,144.054657 383.250885,139.780151 383.258179,131.534714
|
||||
C383.285522,100.544373 383.288055,69.553848 383.201172,38.563663
|
||||
C383.175934,29.557758 376.753723,24.025076 367.909607,25.159098
|
||||
C344.111908,28.210499 320.640198,33.042156 297.906189,40.622456
|
||||
C222.169678,65.875587 160.333786,110.684029 112.396675,174.482834
|
||||
C103.924675,185.758102 96.374557,197.768875 88.989510,209.802567
|
||||
C84.273262,217.487518 86.937126,225.184952 94.670914,228.490997
|
||||
C123.212662,240.692017 151.779144,252.835693 180.376389,264.906006
|
||||
C188.443008,268.310760 193.961975,266.573364 199.006348,259.489166
|
||||
C204.408066,251.903107 209.800613,244.310516 215.677963,236.191559
|
||||
M844.379700,726.872742
|
||||
C864.054138,671.175903 855.751770,619.942566 817.078186,575.342590
|
||||
C781.893372,534.765930 736.089783,518.710632 683.036865,526.280457
|
||||
C644.591187,531.765991 613.266724,551.002502 588.860779,581.289062
|
||||
C545.934998,634.558044 545.733765,712.703369 588.979980,765.434998
|
||||
C626.048889,810.634399 674.305664,829.841797 731.735229,819.907288
|
||||
C785.714111,810.569763 822.549255,777.738037 844.379700,726.872742
|
||||
M449.047333,261.640656
|
||||
C449.159271,251.333984 447.584320,249.732315 437.441071,249.743118
|
||||
C420.615448,249.761032 403.789825,249.761078 386.964203,249.768799
|
||||
C379.285858,249.772324 377.391541,251.460388 377.385712,259.032135
|
||||
C377.338654,320.337036 377.338684,381.641998 377.360657,442.946930
|
||||
C377.363007,449.529022 379.417145,451.453827 386.146393,451.458588
|
||||
C403.638367,451.470886 421.130371,451.448212 438.622345,451.430908
|
||||
C447.613953,451.421997 448.987000,450.064728 448.990662,441.024628
|
||||
C449.014954,381.551880 449.025970,322.079163 449.047333,261.640656
|
||||
M292.535400,480.239929
|
||||
C290.202637,480.238190 287.869781,480.225494 285.537079,480.236847
|
||||
C278.933044,480.269012 277.021820,482.190735 277.001587,488.909210
|
||||
C276.977020,497.074310 276.985809,505.239624 276.998474,513.404785
|
||||
C277.010681,521.294189 278.903137,523.215393 286.624542,523.216248
|
||||
C321.784912,523.220032 356.945282,523.224609 392.105652,523.223755
|
||||
C441.430145,523.222534 490.754669,523.227173 540.079163,523.202026
|
||||
C547.635437,523.198181 549.174683,521.623291 549.189880,514.133850
|
||||
C549.206482,505.968719 549.178467,497.803436 549.153564,489.638275
|
||||
C549.128723,481.511475 547.804626,480.232483 539.480713,480.232727
|
||||
C457.495361,480.235077 375.510010,480.237579 292.535400,480.239929
|
||||
M348.751312,320.366730
|
||||
C347.563721,316.051117 344.477173,314.997894 340.358704,315.021423
|
||||
C322.202057,315.125122 304.044495,315.050476 285.887329,315.082214
|
||||
C279.227448,315.093872 277.011322,317.351166 277.007996,324.149719
|
||||
C276.988617,363.629211 276.991974,403.108673 277.004852,442.588165
|
||||
C277.007050,449.299042 279.175964,451.424103 286.015656,451.431183
|
||||
C304.006287,451.449829 321.996948,451.445831 339.987579,451.429840
|
||||
C347.142944,451.423462 348.853638,449.734131 348.854401,442.663239
|
||||
C348.858765,402.184387 348.848907,361.705505 348.751312,320.366730
|
||||
M695.500244,428.759796
|
||||
C690.168884,428.766327 684.836548,428.708710 679.506470,428.795074
|
||||
C672.334106,428.911255 667.149902,433.159943 665.966980,440.264282
|
||||
C662.319092,462.173737 655.682251,483.149719 646.676086,503.413391
|
||||
C646.397644,504.039886 646.308350,504.750366 645.992249,505.945892
|
||||
C688.632629,490.276337 730.456848,491.779327 773.199768,508.722046
|
||||
C777.372986,487.254089 781.478516,466.773712 785.298279,446.240204
|
||||
C787.518738,434.303436 782.604919,428.740753 770.473877,428.747375
|
||||
C745.815979,428.760803 721.158142,428.756195 695.500244,428.759796
|
||||
M477.365723,436.429138
|
||||
C477.370117,437.927643 477.372803,439.426147 477.379211,440.924652
|
||||
C477.417999,450.013000 478.803314,451.431580 487.740326,451.442108
|
||||
C505.222443,451.462677 522.704529,451.472229 540.186646,451.477753
|
||||
C547.387329,451.480011 549.147156,449.820282 549.154480,442.811829
|
||||
C549.181458,417.004883 549.183655,391.197937 549.167236,365.390991
|
||||
C549.162842,358.448700 547.136902,356.388214 540.297668,356.384766
|
||||
C522.649170,356.375854 505.000580,356.394958 487.352142,356.443115
|
||||
C479.117645,356.465607 477.393768,358.204041 477.383301,366.520325
|
||||
C477.354370,389.496735 477.367493,412.473206 477.365723,436.429138
|
||||
z"/>
|
||||
<path fill="#010101" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M542.682800,45.224068
|
||||
C593.817322,64.716614 638.871155,93.185966 677.282349,131.569260
|
||||
C727.989807,182.239914 762.028564,242.493698 778.095520,312.559113
|
||||
C781.135010,325.813751 783.404907,339.250671 785.806519,352.643524
|
||||
C787.311218,361.034821 781.559875,367.933777 772.991455,367.960571
|
||||
C741.664429,368.058624 710.336853,368.047638 679.009766,367.964020
|
||||
C671.709045,367.944519 667.737244,364.028534 665.587280,356.299255
|
||||
C660.788818,339.047943 656.947144,321.409485 650.598267,304.735016
|
||||
C636.365967,267.356171 614.186951,235.116486 584.447815,208.093491
|
||||
C548.050293,175.020309 505.690002,154.414627 457.342712,145.879486
|
||||
C447.254486,144.098541 443.699158,139.780212 443.699677,129.564072
|
||||
C443.701202,99.903152 443.706024,70.242226 443.740356,40.581329
|
||||
C443.754120,28.679417 449.166595,24.186737 461.046326,25.658094
|
||||
C488.869904,29.104166 515.930359,35.672890 542.682800,45.224068
|
||||
z"/>
|
||||
<path fill="#010101" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M250.212799,604.896851
|
||||
C256.151459,601.320129 261.315094,602.563538 266.346741,606.094482
|
||||
C291.761261,623.928833 319.340820,637.279968 349.452911,644.928711
|
||||
C382.871002,653.417175 416.649292,655.276428 450.935181,650.278381
|
||||
C479.588409,646.101440 506.343689,636.843872 531.886719,623.572754
|
||||
C532.731262,623.133911 533.660034,622.856995 535.285461,622.213013
|
||||
C522.778503,665.497559 525.559875,707.200806 544.615234,748.146301
|
||||
C541.017090,749.551392 537.711182,750.948364 534.334229,752.145264
|
||||
C486.434448,769.122314 437.099304,774.856140 386.505096,771.360840
|
||||
C328.047516,767.322266 273.782776,749.918274 223.453003,720.252441
|
||||
C210.020752,712.335022 197.317078,703.138489 184.565399,694.124084
|
||||
C175.512619,687.724487 174.489395,680.062134 181.821686,672.680603
|
||||
C204.354080,649.996704 227.172363,627.596802 250.212799,604.896851
|
||||
z"/>
|
||||
<path fill="#010101" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M62.787266,531.153564
|
||||
C50.058784,498.524048 42.982471,465.014069 39.789570,430.587708
|
||||
C35.525383,384.610504 40.704552,339.580200 52.943752,295.222534
|
||||
C55.975334,284.235382 62.688972,281.197906 73.238480,285.720001
|
||||
C100.405411,297.365204 127.635887,308.862122 154.806519,320.498779
|
||||
C165.037674,324.880554 167.348129,329.943451 164.548187,341.152130
|
||||
C157.795013,368.186310 155.306396,395.602020 158.376160,423.306793
|
||||
C161.543091,451.888702 168.295303,479.650391 181.543472,505.362518
|
||||
C188.299973,518.475525 196.147614,531.044678 203.858261,543.641602
|
||||
C209.328140,552.577698 209.367096,558.065002 202.062515,565.456421
|
||||
C180.752716,587.019531 159.317520,608.459473 137.827927,629.843628
|
||||
C131.488754,636.151611 123.330544,635.197449 117.540878,627.879395
|
||||
C94.358360,598.576965 76.299995,566.368469 62.787266,531.153564
|
||||
z"/>
|
||||
<path fill="#010101" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M215.437561,236.456177
|
||||
C209.800613,244.310516 204.408066,251.903107 199.006348,259.489166
|
||||
C193.961975,266.573364 188.443008,268.310760 180.376389,264.906006
|
||||
C151.779144,252.835693 123.212662,240.692017 94.670914,228.490997
|
||||
C86.937126,225.184952 84.273262,217.487518 88.989510,209.802567
|
||||
C96.374557,197.768875 103.924675,185.758102 112.396675,174.482834
|
||||
C160.333786,110.684029 222.169678,65.875587 297.906189,40.622456
|
||||
C320.640198,33.042156 344.111908,28.210499 367.909607,25.159098
|
||||
C376.753723,24.025076 383.175934,29.557758 383.201172,38.563663
|
||||
C383.288055,69.553848 383.285522,100.544373 383.258179,131.534714
|
||||
C383.250885,139.780151 379.309235,144.054657 370.203796,145.667511
|
||||
C340.658661,150.900879 312.691864,160.539154 286.676575,175.579193
|
||||
C259.291748,191.410980 235.537338,211.528305 215.437561,236.456177
|
||||
z"/>
|
||||
<path fill="#020202" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M844.236816,727.233337
|
||||
C822.549255,777.738037 785.714111,810.569763 731.735229,819.907288
|
||||
C674.305664,829.841797 626.048889,810.634399 588.979980,765.434998
|
||||
C545.733765,712.703369 545.934998,634.558044 588.860779,581.289062
|
||||
C613.266724,551.002502 644.591187,531.765991 683.036865,526.280457
|
||||
C736.089783,518.710632 781.893372,534.765930 817.078186,575.342590
|
||||
C855.751770,619.942566 864.054138,671.175903 844.236816,727.233337
|
||||
M623.181213,590.679077
|
||||
C620.695068,593.371338 618.071350,595.950134 615.743713,598.773132
|
||||
C585.543640,635.399963 580.470337,686.510132 602.402100,728.147522
|
||||
C635.734558,791.429199 729.669617,819.460815 791.614868,752.051453
|
||||
C841.179993,698.114319 829.429626,614.135498 767.783813,574.686401
|
||||
C722.033569,545.409363 662.291626,551.831970 623.181213,590.679077
|
||||
z"/>
|
||||
<path fill="#020202" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M449.044312,262.123535
|
||||
C449.025970,322.079163 449.014954,381.551880 448.990662,441.024628
|
||||
C448.987000,450.064728 447.613953,451.421997 438.622345,451.430908
|
||||
C421.130371,451.448212 403.638367,451.470886 386.146393,451.458588
|
||||
C379.417145,451.453827 377.363007,449.529022 377.360657,442.946930
|
||||
C377.338684,381.641998 377.338654,320.337036 377.385712,259.032135
|
||||
C377.391541,251.460388 379.285858,249.772324 386.964203,249.768799
|
||||
C403.789825,249.761078 420.615448,249.761032 437.441071,249.743118
|
||||
C447.584320,249.732315 449.159271,251.333984 449.044312,262.123535
|
||||
z"/>
|
||||
<path fill="#030303" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M293.030029,480.239990
|
||||
C375.510010,480.237579 457.495361,480.235077 539.480713,480.232727
|
||||
C547.804626,480.232483 549.128723,481.511475 549.153564,489.638275
|
||||
C549.178467,497.803436 549.206482,505.968719 549.189880,514.133850
|
||||
C549.174683,521.623291 547.635437,523.198181 540.079163,523.202026
|
||||
C490.754669,523.227173 441.430145,523.222534 392.105652,523.223755
|
||||
C356.945282,523.224609 321.784912,523.220032 286.624542,523.216248
|
||||
C278.903137,523.215393 277.010681,521.294189 276.998474,513.404785
|
||||
C276.985809,505.239624 276.977020,497.074310 277.001587,488.909210
|
||||
C277.021820,482.190735 278.933044,480.269012 285.537079,480.236847
|
||||
C287.869781,480.225494 290.202637,480.238190 293.030029,480.239990
|
||||
z"/>
|
||||
<path fill="#010101" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M348.797607,320.796692
|
||||
C348.848907,361.705505 348.858765,402.184387 348.854401,442.663239
|
||||
C348.853638,449.734131 347.142944,451.423462 339.987579,451.429840
|
||||
C321.996948,451.445831 304.006287,451.449829 286.015656,451.431183
|
||||
C279.175964,451.424103 277.007050,449.299042 277.004852,442.588165
|
||||
C276.991974,403.108673 276.988617,363.629211 277.007996,324.149719
|
||||
C277.011322,317.351166 279.227448,315.093872 285.887329,315.082214
|
||||
C304.044495,315.050476 322.202057,315.125122 340.358704,315.021423
|
||||
C344.477173,314.997894 347.563721,316.051117 348.797607,320.796692
|
||||
z"/>
|
||||
<path fill="#010101" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M696.000244,428.759399
|
||||
C721.158142,428.756195 745.815979,428.760803 770.473877,428.747375
|
||||
C782.604919,428.740753 787.518738,434.303436 785.298279,446.240204
|
||||
C781.478516,466.773712 777.372986,487.254089 773.199768,508.722046
|
||||
C730.456848,491.779327 688.632629,490.276337 645.992249,505.945892
|
||||
C646.308350,504.750366 646.397644,504.039886 646.676086,503.413391
|
||||
C655.682251,483.149719 662.319092,462.173737 665.966980,440.264282
|
||||
C667.149902,433.159943 672.334106,428.911255 679.506470,428.795074
|
||||
C684.836548,428.708710 690.168884,428.766327 696.000244,428.759399
|
||||
z"/>
|
||||
<path fill="#030303" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M477.364838,435.939392
|
||||
C477.367493,412.473206 477.354370,389.496735 477.383301,366.520325
|
||||
C477.393768,358.204041 479.117645,356.465607 487.352142,356.443115
|
||||
C505.000580,356.394958 522.649170,356.375854 540.297668,356.384766
|
||||
C547.136902,356.388214 549.162842,358.448700 549.167236,365.390991
|
||||
C549.183655,391.197937 549.181458,417.004883 549.154480,442.811829
|
||||
C549.147156,449.820282 547.387329,451.480011 540.186646,451.477753
|
||||
C522.704529,451.472229 505.222443,451.462677 487.740326,451.442108
|
||||
C478.803314,451.431580 477.417999,450.013000 477.379211,440.924652
|
||||
C477.372803,439.426147 477.370117,437.927643 477.364838,435.939392
|
||||
z"/>
|
||||
<path fill="#FDFDFD" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M623.428284,590.428955
|
||||
C662.291626,551.831970 722.033569,545.409363 767.783813,574.686401
|
||||
C829.429626,614.135498 841.179993,698.114319 791.614868,752.051453
|
||||
C729.669617,819.460815 635.734558,791.429199 602.402100,728.147522
|
||||
C580.470337,686.510132 585.543640,635.399963 615.743713,598.773132
|
||||
C618.071350,595.950134 620.695068,593.371338 623.428284,590.428955
|
||||
M753.192749,737.634033
|
||||
C754.448853,734.181091 757.003845,730.612549 756.708679,727.297729
|
||||
C755.934998,718.609314 747.434387,715.064148 738.870056,719.532654
|
||||
C727.793884,725.311707 716.288269,727.225525 704.209473,723.867004
|
||||
C694.499695,721.167236 686.639954,715.736328 680.694214,706.224365
|
||||
C692.188660,706.224365 702.777649,706.351196 713.359741,706.136597
|
||||
C716.059631,706.081909 719.065552,705.511597 721.356628,704.185852
|
||||
C725.947144,701.529358 726.972290,696.950195 725.887695,692.003601
|
||||
C724.819824,687.133545 720.513672,683.802002 714.750061,683.720276
|
||||
C702.755310,683.550110 690.756348,683.675598 678.759155,683.679321
|
||||
C676.992493,683.679932 675.225830,683.679443 673.231812,683.679443
|
||||
C673.231812,679.278564 673.231812,675.644531 673.231812,671.450012
|
||||
C675.053345,671.450012 676.847046,671.450439 678.640747,671.449951
|
||||
C693.637573,671.446106 708.635498,671.549744 723.630737,671.392578
|
||||
C731.053345,671.314819 735.482605,666.811951 735.452820,660.005798
|
||||
C735.424011,653.403809 730.675354,648.927185 723.424805,648.906067
|
||||
C711.094788,648.870178 698.764526,648.956055 686.434326,648.989014
|
||||
C684.548462,648.994080 682.662537,648.989746 680.430176,648.989746
|
||||
C687.478149,634.829895 706.764771,625.401367 722.198242,628.526733
|
||||
C727.978271,629.697205 733.520325,632.104675 739.117188,634.098938
|
||||
C743.841431,635.782349 748.344910,635.701111 752.358582,632.537964
|
||||
C756.140198,629.557678 757.690857,625.059204 756.029663,620.903198
|
||||
C754.612427,617.357483 751.735474,613.243958 748.449768,611.827332
|
||||
C740.754395,608.509338 732.578857,605.695801 724.326843,604.388489
|
||||
C696.142395,599.923584 666.263123,616.178650 655.606567,641.588196
|
||||
C653.421021,646.799438 651.424805,649.639526 645.181152,649.144714
|
||||
C639.571289,648.700134 635.549622,653.494507 635.076355,659.358887
|
||||
C634.623291,664.973389 637.802246,669.671143 643.081360,671.071411
|
||||
C644.652405,671.488159 646.275757,671.707764 647.913940,672.025146
|
||||
C647.913940,675.843201 647.913940,679.453430 647.913940,683.073547
|
||||
C647.284546,683.313293 646.822266,683.658691 646.382935,683.631653
|
||||
C638.836182,683.166504 634.594727,689.580017 635.174988,695.919861
|
||||
C635.791565,702.656128 640.269714,706.582275 647.910950,706.073669
|
||||
C651.984741,705.802551 653.238464,707.627075 654.684998,710.832764
|
||||
C674.544434,754.843140 726.678955,757.084167 753.192749,737.634033
|
||||
z"/>
|
||||
<path fill="#030303" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M752.909058,737.869873
|
||||
C726.678955,757.084167 674.544434,754.843140 654.684998,710.832764
|
||||
C653.238464,707.627075 651.984741,705.802551 647.910950,706.073669
|
||||
C640.269714,706.582275 635.791565,702.656128 635.174988,695.919861
|
||||
C634.594727,689.580017 638.836182,683.166504 646.382935,683.631653
|
||||
C646.822266,683.658691 647.284546,683.313293 647.913940,683.073547
|
||||
C647.913940,679.453430 647.913940,675.843201 647.913940,672.025146
|
||||
C646.275757,671.707764 644.652405,671.488159 643.081360,671.071411
|
||||
C637.802246,669.671143 634.623291,664.973389 635.076355,659.358887
|
||||
C635.549622,653.494507 639.571289,648.700134 645.181152,649.144714
|
||||
C651.424805,649.639526 653.421021,646.799438 655.606567,641.588196
|
||||
C666.263123,616.178650 696.142395,599.923584 724.326843,604.388489
|
||||
C732.578857,605.695801 740.754395,608.509338 748.449768,611.827332
|
||||
C751.735474,613.243958 754.612427,617.357483 756.029663,620.903198
|
||||
C757.690857,625.059204 756.140198,629.557678 752.358582,632.537964
|
||||
C748.344910,635.701111 743.841431,635.782349 739.117188,634.098938
|
||||
C733.520325,632.104675 727.978271,629.697205 722.198242,628.526733
|
||||
C706.764771,625.401367 687.478149,634.829895 680.430176,648.989746
|
||||
C682.662537,648.989746 684.548462,648.994080 686.434326,648.989014
|
||||
C698.764526,648.956055 711.094788,648.870178 723.424805,648.906067
|
||||
C730.675354,648.927185 735.424011,653.403809 735.452820,660.005798
|
||||
C735.482605,666.811951 731.053345,671.314819 723.630737,671.392578
|
||||
C708.635498,671.549744 693.637573,671.446106 678.640747,671.449951
|
||||
C676.847046,671.450439 675.053345,671.450012 673.231812,671.450012
|
||||
C673.231812,675.644531 673.231812,679.278564 673.231812,683.679443
|
||||
C675.225830,683.679443 676.992493,683.679932 678.759155,683.679321
|
||||
C690.756348,683.675598 702.755310,683.550110 714.750061,683.720276
|
||||
C720.513672,683.802002 724.819824,687.133545 725.887695,692.003601
|
||||
C726.972290,696.950195 725.947144,701.529358 721.356628,704.185852
|
||||
C719.065552,705.511597 716.059631,706.081909 713.359741,706.136597
|
||||
C702.777649,706.351196 692.188660,706.224365 680.694214,706.224365
|
||||
C686.639954,715.736328 694.499695,721.167236 704.209473,723.867004
|
||||
C716.288269,727.225525 727.793884,725.311707 738.870056,719.532654
|
||||
C747.434387,715.064148 755.934998,718.609314 756.708679,727.297729
|
||||
C757.003845,730.612549 754.448853,734.181091 752.909058,737.869873
|
||||
z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,102 @@
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="100%" viewBox="0 0 280 280" xml:space="preserve">
|
||||
|
||||
<path fill="#030303" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M179.664795,154.163696
|
||||
C169.925735,145.617676 159.063232,141.738632 146.328674,142.965469
|
||||
C132.906586,144.258530 118.987137,155.213989 115.758759,167.833267
|
||||
C128.628601,167.833267 141.378876,167.833511 154.129150,167.833130
|
||||
C158.626846,167.833008 163.129028,167.719391 167.619522,167.898788
|
||||
C168.963623,167.952499 171.464752,168.989929 171.422073,169.293793
|
||||
C170.832138,173.493393 170.256882,177.777084 168.817596,181.726608
|
||||
C168.371399,182.950989 165.350250,183.838593 163.498627,183.855560
|
||||
C146.190247,184.014099 128.879822,183.949524 110.981071,183.949524
|
||||
C110.981071,188.137894 110.981071,192.155090 110.981071,196.512497
|
||||
C126.790314,196.512497 142.245834,196.515030 157.701355,196.511353
|
||||
C164.734756,196.509689 165.288559,197.221924 163.282135,204.021454
|
||||
C160.599350,213.113159 162.286896,212.654358 151.976837,212.646820
|
||||
C140.008163,212.638077 128.039490,212.644806 115.229774,212.644806
|
||||
C119.563446,222.597122 125.631157,230.022614 135.032654,234.080811
|
||||
C149.510834,240.330368 163.263092,239.014923 175.763550,228.903076
|
||||
C181.163467,224.534988 181.567734,224.527420 186.442795,229.455887
|
||||
C189.018509,232.059830 191.492950,234.774673 194.194382,237.240860
|
||||
C197.706940,240.447586 196.951965,243.139542 193.718414,245.961624
|
||||
C180.632568,257.382233 165.266052,262.308807 148.122665,261.464874
|
||||
C118.361801,259.999878 96.046753,242.070755 87.529266,212.660858
|
||||
C83.490990,212.660858 79.225090,212.743256 74.963776,212.638748
|
||||
C70.263336,212.523483 69.396347,211.240372 70.719559,206.758530
|
||||
C73.604042,196.988541 73.604042,196.988541 84.691002,196.245468
|
||||
C84.691002,194.384125 84.691002,192.480698 84.691002,190.577286
|
||||
C84.691002,188.595322 84.691002,186.613373 84.691002,183.958191
|
||||
C81.310928,183.958191 77.889481,183.937119 74.468407,183.963806
|
||||
C70.628227,183.993774 69.473946,182.427933 70.640129,178.615372
|
||||
C74.441849,166.186569 71.444397,167.945404 84.711891,167.834518
|
||||
C85.531479,167.827667 86.351189,167.833633 87.466568,167.833633
|
||||
C90.588394,157.281326 95.402390,147.798477 102.679825,139.554642
|
||||
C124.715370,114.592934 168.349274,111.739456 193.137833,133.786133
|
||||
C198.095901,138.195786 198.108429,139.461807 193.347107,144.117249
|
||||
C190.608948,146.794525 188.067291,149.697678 185.143936,152.149536
|
||||
C183.791351,153.283951 181.737854,153.582657 179.664795,154.163696
|
||||
z"/>
|
||||
<path fill="#050505" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M14.236526,75.908188
|
||||
C17.646614,71.790688 20.866325,67.774879 24.695526,64.463959
|
||||
C25.547039,63.727703 29.129824,64.874527 30.461929,66.173103
|
||||
C40.479347,75.938354 50.248154,85.958641 60.792362,96.603516
|
||||
C60.792362,86.437202 60.792362,77.131508 60.792362,67.825821
|
||||
C61.167454,67.589134 61.542545,67.352455 61.917637,67.115768
|
||||
C65.353012,70.516426 68.555359,74.201340 72.277260,77.252495
|
||||
C78.161797,82.076561 80.611282,87.769699 80.011177,95.523521
|
||||
C79.331810,104.301544 79.886703,113.172485 79.835175,122.002617
|
||||
C79.802223,127.650040 78.408539,128.994736 72.713867,129.008423
|
||||
C62.050777,129.034073 51.380283,129.235153 40.729576,128.872498
|
||||
C38.049709,128.781250 35.010292,127.356033 32.871071,125.631927
|
||||
C27.603771,121.386757 22.763531,116.611694 17.751558,112.049706
|
||||
C18.114943,111.491974 18.478327,110.934242 18.841709,110.376511
|
||||
C27.849665,110.376511 36.857620,110.376511 45.865574,110.376511
|
||||
C46.088287,109.960938 46.310997,109.545364 46.533710,109.129791
|
||||
C37.098667,99.862938 27.637606,90.622276 18.254995,81.302643
|
||||
C16.745930,79.803711 15.586824,77.952454 14.236526,75.908188
|
||||
z"/>
|
||||
<path fill="#030303" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M221.431000,83.975708
|
||||
C221.431335,88.206512 221.431335,91.943855 221.431335,96.947372
|
||||
C231.938507,86.386665 241.496582,76.739479 251.103485,67.141159
|
||||
C255.876389,62.372509 256.578705,62.351269 261.166351,66.949326
|
||||
C270.601868,76.406273 270.992371,74.833458 261.312622,84.409752
|
||||
C252.938736,92.694168 244.550644,100.964233 235.195679,110.201553
|
||||
C245.602127,110.201553 254.714767,110.201553 265.337189,110.201553
|
||||
C258.793121,116.705040 253.415192,122.256348 247.755768,127.504074
|
||||
C246.587219,128.587616 244.374756,128.931381 242.635910,128.951019
|
||||
C231.480774,129.077011 220.323120,128.962402 209.167191,129.046448
|
||||
C204.732758,129.079849 202.844009,127.065422 202.878830,122.673340
|
||||
C202.968582,111.351021 202.781738,100.024902 203.017395,88.706795
|
||||
C203.061249,86.600677 204.067657,84.081360 205.477325,82.515854
|
||||
C210.251587,77.213799 215.406418,72.254417 221.430679,66.132843
|
||||
C221.430679,72.772995 221.430679,78.127617 221.431000,83.975708
|
||||
z"/>
|
||||
<path fill="#020202" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M166.112900,77.087509
|
||||
C159.189651,84.083305 152.451294,90.760605 145.869446,97.588783
|
||||
C142.792252,100.781158 140.061600,100.850555 136.962494,97.690765
|
||||
C129.156662,89.732132 121.157227,81.959595 113.499001,73.863129
|
||||
C111.958031,72.233978 110.906944,69.536240 110.794876,67.273491
|
||||
C110.449814,60.305935 110.669098,53.310436 110.669098,45.635742
|
||||
C117.562439,52.622627 124.050461,59.198685 130.538483,65.774742
|
||||
C130.965698,65.512833 131.392929,65.250923 131.820145,64.989014
|
||||
C131.820145,52.931091 131.819489,40.873173 131.820755,28.815252
|
||||
C131.820984,26.651915 131.891037,24.486519 131.819107,22.325727
|
||||
C131.713104,19.141384 133.281433,17.778091 136.361969,17.817129
|
||||
C139.523300,17.857195 142.690948,17.939859 145.846069,17.793287
|
||||
C149.793259,17.609919 151.079636,19.515121 151.052200,23.247364
|
||||
C150.949829,37.183502 151.011276,51.120838 151.011276,64.987717
|
||||
C157.622391,58.388733 164.130264,51.892811 170.638123,45.396889
|
||||
C171.020706,45.577911 171.403305,45.758934 171.785889,45.939957
|
||||
C171.785889,54.086819 172.174210,62.264236 171.537216,70.360985
|
||||
C171.357971,72.639214 168.169891,74.680725 166.112900,77.087509
|
||||
z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.9 KiB |
@@ -0,0 +1,206 @@
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="100%" viewBox="0 0 1036 912" enable-background="new 0 0 1036 912" xml:space="preserve">
|
||||
<path fill="#FFFFFF" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M605.000000,913.000000
|
||||
C403.359131,913.000000 202.218246,913.000000 1.038687,913.000000
|
||||
C1.038687,609.064758 1.038687,305.129547 1.038687,1.097151
|
||||
C346.224274,1.097151 691.448547,1.097151 1036.836426,1.097151
|
||||
C1036.836426,304.999939 1036.836426,608.999939 1036.836426,913.000000
|
||||
C893.129395,913.000000 749.314697,913.000000 605.000000,913.000000
|
||||
M226.549728,700.947266
|
||||
C338.854889,834.103760 509.968048,842.465454 616.632690,799.727051
|
||||
C616.632690,751.629333 616.632690,703.613281 616.632690,655.302368
|
||||
C517.733765,731.227539 353.300171,701.244080 300.422974,561.839722
|
||||
C302.496246,561.839722 304.489563,561.839905 306.482880,561.839722
|
||||
C379.977509,561.832397 453.472168,561.835938 526.966797,561.806396
|
||||
C538.130127,561.801941 543.866821,557.479492 546.986328,546.785095
|
||||
C551.596497,530.980591 556.171570,515.165588 560.677307,499.331055
|
||||
C564.450684,486.070038 557.717407,477.187042 544.062378,477.189850
|
||||
C457.402069,477.207611 370.741760,477.212799 284.081451,477.222137
|
||||
C282.440765,477.222321 280.800079,477.222198 278.799377,477.222198
|
||||
C276.410278,454.126587 276.508392,431.424591 278.967682,408.314484
|
||||
C281.212646,408.314484 283.187073,408.314667 285.161499,408.314453
|
||||
C379.487915,408.304138 473.814331,408.296997 568.140747,408.278992
|
||||
C578.979248,408.276947 584.430786,404.273651 587.481934,393.881226
|
||||
C592.166138,377.926178 596.771484,361.947601 601.308594,345.950165
|
||||
C601.981018,343.579285 602.507935,341.031738 602.397034,338.598053
|
||||
C601.971436,329.256348 595.677246,323.969147 585.195251,323.968048
|
||||
C491.868713,323.958221 398.542206,323.967712 305.215668,323.971497
|
||||
C303.428162,323.971558 301.640625,323.971466 299.489319,323.971466
|
||||
C299.799805,322.613434 299.884064,321.624298 300.246185,320.750244
|
||||
C315.127808,284.828522 336.819427,254.060486 368.133606,230.447647
|
||||
C399.408203,206.864670 434.627472,193.720169 473.507812,190.280640
|
||||
C505.235901,187.473831 536.410339,190.232300 566.554565,201.075760
|
||||
C591.942383,210.208237 614.334778,224.351273 634.109070,242.646011
|
||||
C647.079346,254.645859 657.737122,254.592361 670.098572,242.246033
|
||||
C689.200256,223.167694 708.333191,204.120407 727.379456,184.986816
|
||||
C736.750061,175.573212 736.629517,163.203506 727.236816,154.014023
|
||||
C692.289856,119.823387 652.000854,94.296219 605.319885,79.296371
|
||||
C559.029663,64.422089 511.552551,60.042652 463.218628,63.899776
|
||||
C411.582520,68.020424 362.677917,81.692566 317.675323,107.556160
|
||||
C245.393661,149.097366 195.642441,209.924683 166.174744,287.600006
|
||||
C161.657059,299.508392 157.997482,311.742340 153.890778,323.975433
|
||||
C134.370636,323.975433 114.879005,323.928955 95.387772,323.995941
|
||||
C86.284355,324.027222 80.237457,328.488434 77.638489,337.172699
|
||||
C72.633011,353.898132 67.660202,370.635193 62.899822,387.431488
|
||||
C59.281521,400.198151 65.611572,408.319489 78.950066,408.335266
|
||||
C97.615288,408.357330 116.280602,408.301788 134.945877,408.279999
|
||||
C136.727921,408.277924 138.509964,408.279694 140.076965,408.279694
|
||||
C140.076965,431.527985 140.076965,454.227509 140.076965,477.215851
|
||||
C125.721504,477.215851 111.579056,477.222046 97.436607,477.213867
|
||||
C85.588921,477.207031 80.399918,480.956909 77.051399,492.219421
|
||||
C72.313263,508.155945 67.669937,524.120667 62.983341,540.072510
|
||||
C59.152515,553.111694 65.688339,561.835999 79.267487,561.832581
|
||||
C102.765762,561.826782 126.264038,561.835327 149.762314,561.837830
|
||||
C151.387802,561.838013 153.013275,561.837830 154.653061,561.837830
|
||||
C168.423782,613.595154 191.822403,659.811951 226.549728,700.947266
|
||||
M859.500122,504.219177
|
||||
C799.175049,504.227386 738.849792,504.149597 678.524963,504.277191
|
||||
C644.285706,504.349609 618.383301,529.249939 618.241272,563.011047
|
||||
C617.923706,638.499329 617.923828,713.990662 618.211548,789.479126
|
||||
C618.330505,820.696411 643.481812,845.777527 674.617859,845.883667
|
||||
C751.106445,846.144409 827.596802,846.068054 904.085938,845.892761
|
||||
C916.904602,845.863403 928.506226,841.530334 938.512146,833.174011
|
||||
C953.228943,820.883423 959.655090,804.961975 959.652222,786.111938
|
||||
C959.640991,712.955261 959.630554,639.798584 959.590576,566.641907
|
||||
C959.588806,563.480530 959.387878,560.311768 959.126404,557.159119
|
||||
C956.865662,529.900696 935.690125,507.403656 908.444641,505.009796
|
||||
C892.562622,503.614380 876.488037,504.409424 859.500122,504.219177
|
||||
z"/>
|
||||
<path fill="#010101" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M226.324890,700.671021
|
||||
C191.822403,659.811951 168.423782,613.595154 154.653061,561.837830
|
||||
C153.013275,561.837830 151.387802,561.838013 149.762314,561.837830
|
||||
C126.264038,561.835327 102.765762,561.826782 79.267487,561.832581
|
||||
C65.688339,561.835999 59.152515,553.111694 62.983341,540.072510
|
||||
C67.669937,524.120667 72.313263,508.155945 77.051399,492.219421
|
||||
C80.399918,480.956909 85.588921,477.207031 97.436607,477.213867
|
||||
C111.579056,477.222046 125.721504,477.215851 140.076965,477.215851
|
||||
C140.076965,454.227509 140.076965,431.527985 140.076965,408.279694
|
||||
C138.509964,408.279694 136.727921,408.277924 134.945877,408.279999
|
||||
C116.280602,408.301788 97.615288,408.357330 78.950066,408.335266
|
||||
C65.611572,408.319489 59.281521,400.198151 62.899822,387.431488
|
||||
C67.660202,370.635193 72.633011,353.898132 77.638489,337.172699
|
||||
C80.237457,328.488434 86.284355,324.027222 95.387772,323.995941
|
||||
C114.879005,323.928955 134.370636,323.975433 153.890778,323.975433
|
||||
C157.997482,311.742340 161.657059,299.508392 166.174744,287.600006
|
||||
C195.642441,209.924683 245.393661,149.097366 317.675323,107.556160
|
||||
C362.677917,81.692566 411.582520,68.020424 463.218628,63.899776
|
||||
C511.552551,60.042652 559.029663,64.422089 605.319885,79.296371
|
||||
C652.000854,94.296219 692.289856,119.823387 727.236816,154.014023
|
||||
C736.629517,163.203506 736.750061,175.573212 727.379456,184.986816
|
||||
C708.333191,204.120407 689.200256,223.167694 670.098572,242.246033
|
||||
C657.737122,254.592361 647.079346,254.645859 634.109070,242.646011
|
||||
C614.334778,224.351273 591.942383,210.208237 566.554565,201.075760
|
||||
C536.410339,190.232300 505.235901,187.473831 473.507812,190.280640
|
||||
C434.627472,193.720169 399.408203,206.864670 368.133606,230.447647
|
||||
C336.819427,254.060486 315.127808,284.828522 300.246185,320.750244
|
||||
C299.884064,321.624298 299.799805,322.613434 299.489319,323.971466
|
||||
C301.640625,323.971466 303.428162,323.971558 305.215668,323.971497
|
||||
C398.542206,323.967712 491.868713,323.958221 585.195251,323.968048
|
||||
C595.677246,323.969147 601.971436,329.256348 602.397034,338.598053
|
||||
C602.507935,341.031738 601.981018,343.579285 601.308594,345.950165
|
||||
C596.771484,361.947601 592.166138,377.926178 587.481934,393.881226
|
||||
C584.430786,404.273651 578.979248,408.276947 568.140747,408.278992
|
||||
C473.814331,408.296997 379.487915,408.304138 285.161499,408.314453
|
||||
C283.187073,408.314667 281.212646,408.314484 278.967682,408.314484
|
||||
C276.508392,431.424591 276.410278,454.126587 278.799377,477.222198
|
||||
C280.800079,477.222198 282.440765,477.222321 284.081451,477.222137
|
||||
C370.741760,477.212799 457.402069,477.207611 544.062378,477.189850
|
||||
C557.717407,477.187042 564.450684,486.070038 560.677307,499.331055
|
||||
C556.171570,515.165588 551.596497,530.980591 546.986328,546.785095
|
||||
C543.866821,557.479492 538.130127,561.801941 526.966797,561.806396
|
||||
C453.472168,561.835938 379.977509,561.832397 306.482880,561.839722
|
||||
C304.489563,561.839905 302.496246,561.839722 300.422974,561.839722
|
||||
C353.300171,701.244080 517.733765,731.227539 616.632690,655.302368
|
||||
C616.632690,703.613281 616.632690,751.629333 616.632690,799.727051
|
||||
C509.968048,842.465454 338.854889,834.103760 226.324890,700.671021
|
||||
z"/>
|
||||
<path fill="#020202" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M860.000122,504.219055
|
||||
C876.488037,504.409424 892.562622,503.614380 908.444641,505.009796
|
||||
C935.690125,507.403656 956.865662,529.900696 959.126404,557.159119
|
||||
C959.387878,560.311768 959.588806,563.480530 959.590576,566.641907
|
||||
C959.630554,639.798584 959.640991,712.955261 959.652222,786.111938
|
||||
C959.655090,804.961975 953.228943,820.883423 938.512146,833.174011
|
||||
C928.506226,841.530334 916.904602,845.863403 904.085938,845.892761
|
||||
C827.596802,846.068054 751.106445,846.144409 674.617859,845.883667
|
||||
C643.481812,845.777527 618.330505,820.696411 618.211548,789.479126
|
||||
C617.923828,713.990662 617.923706,638.499329 618.241272,563.011047
|
||||
C618.383301,529.249939 644.285706,504.349609 678.524963,504.277191
|
||||
C738.849792,504.149597 799.175049,504.227386 860.000122,504.219055
|
||||
M839.004150,604.554993
|
||||
C807.443909,604.554993 775.883667,604.554993 744.323425,604.554993
|
||||
C743.964233,603.957092 743.605103,603.359192 743.245911,602.761292
|
||||
C755.541321,590.657043 767.836731,578.552795 780.290710,566.292419
|
||||
C766.062317,566.292419 752.078430,566.119446 738.106201,566.447083
|
||||
C735.494873,566.508301 732.333679,567.965637 730.440186,569.816162
|
||||
C716.737732,583.207275 703.285095,596.854431 689.776733,610.443665
|
||||
C684.411194,615.841309 679.104919,621.297852 673.094666,627.416992
|
||||
C692.690979,646.794800 712.207581,666.128601 731.813782,685.371094
|
||||
C732.725403,686.265686 734.435852,686.736328 735.777405,686.742859
|
||||
C749.940491,686.811951 764.104187,686.752197 778.267700,686.700562
|
||||
C778.938477,686.698120 779.608337,686.438232 782.160767,685.905151
|
||||
C769.029541,672.970764 756.898926,661.021973 744.300354,648.612305
|
||||
C784.543945,648.612305 824.296326,648.612305 864.127686,648.612305
|
||||
C864.127686,633.752991 864.127686,619.349548 864.127686,604.558899
|
||||
C855.856506,604.558899 847.891785,604.558899 839.004150,604.554993
|
||||
M869.961365,689.538452
|
||||
C862.317993,681.873779 854.776428,674.102417 846.946228,666.633606
|
||||
C845.360840,665.121399 842.699280,664.018311 840.509949,663.976624
|
||||
C827.521973,663.729248 814.526855,663.840515 801.534180,663.862427
|
||||
C800.325439,663.864502 799.117004,664.084473 796.303101,664.361084
|
||||
C809.489075,677.198547 821.695435,689.082336 834.890015,701.928162
|
||||
C793.545532,701.928162 753.572021,701.928162 713.604736,701.928162
|
||||
C713.604736,716.922668 713.604736,731.332520 713.604736,746.192627
|
||||
C753.753113,746.192627 793.601929,746.192627 833.450806,746.192627
|
||||
C833.722229,746.616394 833.993652,747.040161 834.265076,747.463928
|
||||
C821.989380,759.482971 809.713745,771.502075 797.001465,783.948608
|
||||
C812.462585,783.948608 827.392212,784.024536 842.318481,783.833191
|
||||
C843.839966,783.813660 845.612427,782.460999 846.809509,781.266907
|
||||
C860.132996,767.976685 873.319580,754.549194 886.645142,741.261047
|
||||
C892.471130,735.451538 898.531494,729.877136 904.578064,724.107727
|
||||
C892.608582,712.160095 881.531250,701.102966 869.961365,689.538452
|
||||
z"/>
|
||||
<path fill="#FDFDFD" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M839.465637,604.556946
|
||||
C847.891785,604.558899 855.856506,604.558899 864.127686,604.558899
|
||||
C864.127686,619.349548 864.127686,633.752991 864.127686,648.612305
|
||||
C824.296326,648.612305 784.543945,648.612305 744.300354,648.612305
|
||||
C756.898926,661.021973 769.029541,672.970764 782.160767,685.905151
|
||||
C779.608337,686.438232 778.938477,686.698120 778.267700,686.700562
|
||||
C764.104187,686.752197 749.940491,686.811951 735.777405,686.742859
|
||||
C734.435852,686.736328 732.725403,686.265686 731.813782,685.371094
|
||||
C712.207581,666.128601 692.690979,646.794800 673.094666,627.416992
|
||||
C679.104919,621.297852 684.411194,615.841309 689.776733,610.443665
|
||||
C703.285095,596.854431 716.737732,583.207275 730.440186,569.816162
|
||||
C732.333679,567.965637 735.494873,566.508301 738.106201,566.447083
|
||||
C752.078430,566.119446 766.062317,566.292419 780.290710,566.292419
|
||||
C767.836731,578.552795 755.541321,590.657043 743.245911,602.761292
|
||||
C743.605103,603.359192 743.964233,603.957092 744.323425,604.554993
|
||||
C775.883667,604.554993 807.443909,604.554993 839.465637,604.556946
|
||||
z"/>
|
||||
<path fill="#FDFEFD" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M870.207642,689.792114
|
||||
C881.531250,701.102966 892.608582,712.160095 904.578064,724.107727
|
||||
C898.531494,729.877136 892.471130,735.451538 886.645142,741.261047
|
||||
C873.319580,754.549194 860.132996,767.976685 846.809509,781.266907
|
||||
C845.612427,782.460999 843.839966,783.813660 842.318481,783.833191
|
||||
C827.392212,784.024536 812.462585,783.948608 797.001465,783.948608
|
||||
C809.713745,771.502075 821.989380,759.482971 834.265076,747.463928
|
||||
C833.993652,747.040161 833.722229,746.616394 833.450806,746.192627
|
||||
C793.601929,746.192627 753.753113,746.192627 713.604736,746.192627
|
||||
C713.604736,731.332520 713.604736,716.922668 713.604736,701.928162
|
||||
C753.572021,701.928162 793.545532,701.928162 834.890015,701.928162
|
||||
C821.695435,689.082336 809.489075,677.198547 796.303101,664.361084
|
||||
C799.117004,664.084473 800.325439,663.864502 801.534180,663.862427
|
||||
C814.526855,663.840515 827.521973,663.729248 840.509949,663.976624
|
||||
C842.699280,664.018311 845.360840,665.121399 846.946228,666.633606
|
||||
C854.776428,674.102417 862.317993,681.873779 870.207642,689.792114
|
||||
z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,321 @@
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="100%" viewBox="0 0 280 280" xml:space="preserve">
|
||||
|
||||
<path fill="#020202" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M245.581940,139.415298
|
||||
C266.168579,160.506378 273.411346,185.195969 264.725830,213.029587
|
||||
C256.165588,240.461670 236.888840,257.619843 208.557251,263.354553
|
||||
C187.120911,267.693634 167.704926,262.162354 150.028549,248.546036
|
||||
C158.118713,244.947571 160.498016,238.620071 160.005280,230.629898
|
||||
C159.739136,226.313766 160.076187,221.963562 159.915726,217.637070
|
||||
C159.686493,211.456390 156.826508,206.996002 151.136475,204.198029
|
||||
C149.811356,203.546417 148.669189,201.613129 148.333527,200.075150
|
||||
C147.846619,197.844299 148.297638,195.427094 148.123550,193.106247
|
||||
C147.581772,185.884003 142.018005,180.410599 134.780075,180.053116
|
||||
C131.305328,179.881470 127.815079,180.023544 123.591125,180.023544
|
||||
C128.856598,154.638794 142.434067,135.953125 165.690552,125.732613
|
||||
C194.753357,112.960411 221.577713,118.117241 245.581940,139.415298
|
||||
M194.190155,188.689972
|
||||
C187.383865,195.505386 180.468460,202.218079 173.868866,209.228088
|
||||
C172.638199,210.535278 171.601166,213.776764 172.335831,214.841949
|
||||
C174.263382,217.636627 176.314819,215.379868 177.965118,213.726685
|
||||
C191.105377,200.563568 204.285156,187.438629 217.264694,174.118668
|
||||
C218.379333,172.974792 218.135651,170.507294 218.522964,168.654663
|
||||
C216.681396,169.056656 214.275253,168.855118 213.092545,169.961472
|
||||
C206.789764,175.857315 200.793137,182.080475 194.190155,188.689972
|
||||
M219.989288,216.447449
|
||||
C225.427444,209.010330 223.215179,200.233276 215.325943,197.945831
|
||||
C210.303848,196.489700 205.090988,198.576080 202.485901,203.084900
|
||||
C199.922745,207.521149 200.773148,213.226166 204.526489,216.774048
|
||||
C208.716263,220.734482 213.703064,220.803558 219.989288,216.447449
|
||||
M173.276428,165.493713
|
||||
C168.596298,168.341034 166.871811,172.567490 167.952972,177.805023
|
||||
C168.871216,182.253265 171.750702,185.128342 176.222031,186.066238
|
||||
C181.077286,187.084656 185.086899,185.534393 187.884720,181.373077
|
||||
C190.616974,177.309296 190.339905,172.304031 187.325577,168.385284
|
||||
C184.182022,164.298538 179.934311,163.261154 173.276428,165.493713
|
||||
z"/>
|
||||
<path fill="#080808" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M131.514984,187.667023
|
||||
C139.037643,187.917099 140.616089,189.558258 140.643219,196.750473
|
||||
C140.660675,201.380096 140.646484,206.009857 140.646484,211.129517
|
||||
C142.286301,211.129517 143.569122,211.084549 144.847916,211.137436
|
||||
C149.992035,211.350159 152.114212,213.402313 152.218918,218.478882
|
||||
C152.315109,223.143204 152.262711,227.811111 152.238327,232.477249
|
||||
C152.202560,239.325546 150.586853,240.941757 143.688538,241.098282
|
||||
C142.884964,241.116516 142.080643,241.100723 140.649719,241.100723
|
||||
C140.649719,246.316116 140.684326,251.252014 140.641052,256.187256
|
||||
C140.585632,262.508667 138.480957,264.778534 132.344803,264.785278
|
||||
C96.514557,264.824677 60.684219,264.828400 24.853987,264.784698
|
||||
C19.000767,264.777557 16.886604,262.475220 16.833263,256.493256
|
||||
C16.794628,252.160629 16.805796,247.827255 16.832159,243.494446
|
||||
C16.873474,236.704086 18.567734,234.996078 25.365978,234.830597
|
||||
C26.162138,234.811218 26.959177,234.828003 28.044827,234.828003
|
||||
C28.044827,229.066299 28.044827,223.658386 28.044827,217.885574
|
||||
C26.629847,217.789093 25.193739,217.684677 23.756741,217.594376
|
||||
C19.152925,217.305099 16.780289,214.939804 16.814152,210.244751
|
||||
C16.849007,205.412064 16.785288,200.578522 16.838316,195.746155
|
||||
C16.904078,189.753632 18.941938,187.693451 24.987814,187.675598
|
||||
C39.819752,187.631805 54.651909,187.662491 69.483978,187.663193
|
||||
C71.287483,187.663269 73.090988,187.663208 75.595474,187.663208
|
||||
C75.595474,185.512787 75.551666,183.748886 75.603676,181.987823
|
||||
C75.761078,176.657913 75.093994,171.118622 76.332588,166.047943
|
||||
C78.313751,157.937317 76.558784,151.092957 71.789932,144.753784
|
||||
C70.787582,143.421371 68.962540,142.294891 67.324753,141.926620
|
||||
C61.007858,140.506119 54.455982,139.962799 48.275269,138.134079
|
||||
C23.929367,130.930771 14.303538,115.637840 18.317024,90.554520
|
||||
C19.362354,84.021454 21.243486,77.571892 23.294703,71.264122
|
||||
C25.342789,64.965965 31.389347,62.830349 36.951077,66.238937
|
||||
C46.454979,72.063545 56.060680,77.773438 65.176849,84.168396
|
||||
C83.088341,96.733246 87.867546,114.163834 78.586441,134.018036
|
||||
C76.158829,139.211182 77.006912,142.592651 81.464905,147.433701
|
||||
C83.461159,140.925674 85.819016,135.323349 86.847748,129.486740
|
||||
C89.716393,113.211372 100.922966,103.152100 117.502098,102.987213
|
||||
C121.812157,102.944359 126.152756,103.375519 130.436981,103.921349
|
||||
C137.506500,104.822037 140.475357,109.470940 138.647888,116.351555
|
||||
C132.479126,139.577637 115.736832,149.017975 92.673225,142.285324
|
||||
C92.055206,142.104904 91.401413,142.046997 90.506439,141.885406
|
||||
C81.285698,155.863144 81.969841,171.548447 81.926964,187.666840
|
||||
C98.531349,187.666840 114.800179,187.666840 131.514984,187.667023
|
||||
M30.355818,243.608475
|
||||
C33.620003,244.770340 36.803234,246.306442 40.161602,247.032974
|
||||
C60.957745,251.531845 81.996941,251.954620 102.998215,249.282455
|
||||
C111.202492,248.238556 119.157913,245.238876 127.985313,243.223343
|
||||
C127.921707,246.520462 129.005188,250.559509 127.482864,252.913132
|
||||
C125.849388,255.438614 121.766220,256.379608 118.629097,258.078674
|
||||
C123.854500,258.078674 128.905533,258.078674 133.958069,258.078674
|
||||
C133.958069,252.275604 133.958069,246.760452 133.958069,240.592499
|
||||
C131.023529,241.165771 128.531265,241.652649 125.301506,242.249573
|
||||
C123.418419,242.870621 121.567833,243.625427 119.647240,244.092133
|
||||
C99.554466,248.974731 79.137024,249.065552 58.752422,247.264252
|
||||
C49.425594,246.440094 40.261440,243.774994 30.469868,241.331039
|
||||
C28.068764,241.331039 25.667660,241.331039 23.269331,241.331039
|
||||
C23.269331,247.160934 23.269331,252.579620 23.269331,258.157684
|
||||
C28.557928,258.157684 33.602814,258.157684 39.068638,258.157684
|
||||
C28.219160,252.593735 26.926825,250.379044 30.355818,243.608475
|
||||
M47.331364,131.280624
|
||||
C53.729332,132.834137 60.127300,134.387650 66.525269,135.941162
|
||||
C66.795982,135.502899 67.066689,135.064636 67.337395,134.626373
|
||||
C65.101067,130.438705 63.214252,126.012215 60.551853,122.115669
|
||||
C55.774277,115.123421 50.416821,108.528564 45.588940,101.568398
|
||||
C44.742332,100.347870 45.300655,98.152817 45.210133,96.407837
|
||||
C46.841187,96.689461 48.610638,96.674210 50.058342,97.349113
|
||||
C51.163902,97.864510 51.969223,99.179329 52.724716,100.266022
|
||||
C59.663364,110.246521 66.561974,120.254860 73.250465,129.934143
|
||||
C80.113228,116.294617 77.410454,101.421242 64.730865,91.950752
|
||||
C55.028923,84.704308 44.443523,78.643089 34.278702,72.012360
|
||||
C31.558155,70.237694 29.777657,71.064217 29.057060,73.957626
|
||||
C27.293264,81.039818 24.948883,88.091469 24.220016,95.295227
|
||||
C22.298897,114.282631 28.740477,124.082359 47.331364,131.280624
|
||||
M29.143480,196.853836
|
||||
C29.791719,195.989014 30.439959,195.124176 31.195951,194.115601
|
||||
C28.443497,194.115601 25.982605,194.115601 23.346436,194.115601
|
||||
C23.346436,199.820251 23.346436,205.364929 23.346436,210.790558
|
||||
C60.497715,210.790558 97.339554,210.790558 133.975403,210.790558
|
||||
C133.975403,205.006866 133.975403,199.587265 133.975403,194.203171
|
||||
C131.302048,194.203171 128.950974,194.203171 125.965691,194.203171
|
||||
C129.785492,197.204117 128.306778,199.028625 125.307068,200.293655
|
||||
C122.869057,201.321823 120.367584,202.377853 117.789062,202.866898
|
||||
C91.853050,207.785751 65.869553,207.930374 39.972271,202.773315
|
||||
C36.140888,202.010361 32.687489,199.349258 29.143480,196.853836
|
||||
M109.400764,126.899445
|
||||
C104.785385,129.845230 100.170006,132.791016 94.691971,136.287384
|
||||
C116.939491,142.667160 127.353615,131.244598 132.510712,114.812096
|
||||
C133.459183,111.789902 132.397888,110.315109 129.426620,110.072937
|
||||
C125.126602,109.722458 120.798874,109.084435 116.515198,109.278954
|
||||
C103.877777,109.852783 95.430588,117.357117 93.977409,128.619843
|
||||
C99.151939,125.684715 104.376648,122.515160 109.827286,119.798988
|
||||
C110.949959,119.239540 112.915543,120.371620 114.492622,120.724052
|
||||
C113.899590,122.216797 113.494293,123.830933 112.638802,125.153961
|
||||
C112.171371,125.876854 110.942009,126.107063 109.400764,126.899445
|
||||
M80.009178,228.353729
|
||||
C98.538544,229.025742 116.952873,228.631210 134.795914,222.552048
|
||||
C103.549431,227.405319 72.449089,228.657867 41.317654,219.788437
|
||||
C45.633919,224.688126 57.741947,227.340164 80.009178,228.353729
|
||||
M136.072128,233.231415
|
||||
C135.621735,233.581482 135.171326,233.931564 134.276489,234.627090
|
||||
C138.539352,234.627090 142.080200,234.627090 145.800522,234.627090
|
||||
C145.800522,228.849777 145.800522,223.322662 145.800522,217.031418
|
||||
C142.847580,217.745026 140.318726,218.356171 137.299469,219.085815
|
||||
C141.416885,223.391983 140.799637,229.973740 136.072128,233.231415
|
||||
M40.600662,224.361053
|
||||
C40.970074,222.546173 41.339489,220.731308 41.879658,218.077530
|
||||
C39.497719,217.902039 37.258606,217.737061 35.013187,217.571625
|
||||
C35.013187,223.692383 35.013187,229.080505 35.013187,234.665741
|
||||
C38.822170,234.665741 42.344856,234.665741 47.364601,234.665741
|
||||
C42.141552,232.529358 39.590347,229.892563 40.600662,224.361053
|
||||
z"/>
|
||||
<path fill="#020202" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M165.178497,101.997345
|
||||
C165.178711,92.351677 165.178711,83.205231 165.178711,73.318695
|
||||
C163.196045,73.318695 161.447968,73.387062 159.706863,73.305946
|
||||
C154.373627,73.057472 150.487000,70.454697 148.457733,65.575844
|
||||
C146.438446,60.720951 147.004807,55.802120 150.791260,52.133629
|
||||
C162.863678,40.437302 175.082611,28.885641 187.479935,17.534742
|
||||
C191.950882,13.441162 198.940323,13.595627 203.509705,17.791971
|
||||
C215.767532,29.049107 227.885376,40.464722 239.830521,52.052727
|
||||
C243.591293,55.701069 244.214035,60.568409 242.196335,65.479294
|
||||
C240.175049,70.398849 236.373886,73.011253 231.040878,73.277679
|
||||
C229.397552,73.359787 227.746658,73.290443 225.597839,73.290443
|
||||
C225.597839,88.338379 225.597839,103.068321 225.597839,118.601845
|
||||
C205.278015,111.028168 185.504562,110.605377 165.178299,118.858055
|
||||
C165.178299,112.951210 165.178299,107.723885 165.178497,101.997345
|
||||
z"/>
|
||||
<path fill="#EEEEEE" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M194.440369,188.440170
|
||||
C200.793137,182.080475 206.789764,175.857315 213.092545,169.961472
|
||||
C214.275253,168.855118 216.681396,169.056656 218.522964,168.654663
|
||||
C218.135651,170.507294 218.379333,172.974792 217.264694,174.118668
|
||||
C204.285156,187.438629 191.105377,200.563568 177.965118,213.726685
|
||||
C176.314819,215.379868 174.263382,217.636627 172.335831,214.841949
|
||||
C171.601166,213.776764 172.638199,210.535278 173.868866,209.228088
|
||||
C180.468460,202.218079 187.383865,195.505386 194.440369,188.440170
|
||||
z"/>
|
||||
<path fill="#F2F2F2" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M219.719604,216.714111
|
||||
C213.703064,220.803558 208.716263,220.734482 204.526489,216.774048
|
||||
C200.773148,213.226166 199.922745,207.521149 202.485901,203.084900
|
||||
C205.090988,198.576080 210.303848,196.489700 215.325943,197.945831
|
||||
C223.215179,200.233276 225.427444,209.010330 219.719604,216.714111
|
||||
M216.838272,209.994644
|
||||
C215.823059,207.990723 215.220612,205.474670 213.626099,204.189499
|
||||
C212.773026,203.501938 209.693359,204.401154 208.530258,205.526535
|
||||
C206.323578,207.661652 206.622116,210.498672 209.089539,212.408997
|
||||
C212.031647,214.686905 214.617416,213.736679 216.838272,209.994644
|
||||
z"/>
|
||||
<path fill="#F2F2F2" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M173.613953,165.306366
|
||||
C179.934311,163.261154 184.182022,164.298538 187.325577,168.385284
|
||||
C190.339905,172.304031 190.616974,177.309296 187.884720,181.373077
|
||||
C185.086899,185.534393 181.077286,187.084656 176.222031,186.066238
|
||||
C171.750702,185.128342 168.871216,182.253265 167.952972,177.805023
|
||||
C166.871811,172.567490 168.596298,168.341034 173.613953,165.306366
|
||||
M173.856064,174.721664
|
||||
C175.009354,176.479233 176.001541,179.509445 177.355637,179.680862
|
||||
C179.157135,179.908905 182.102051,178.348633 182.926712,176.727783
|
||||
C183.571594,175.460312 182.304138,172.127747 180.887527,171.127762
|
||||
C177.808334,168.954147 175.370041,170.713211 173.856064,174.721664
|
||||
z"/>
|
||||
<path fill="#FAFAFA" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M46.957455,131.166046
|
||||
C28.740477,124.082359 22.298897,114.282631 24.220016,95.295227
|
||||
C24.948883,88.091469 27.293264,81.039818 29.057060,73.957626
|
||||
C29.777657,71.064217 31.558155,70.237694 34.278702,72.012360
|
||||
C44.443523,78.643089 55.028923,84.704308 64.730865,91.950752
|
||||
C77.410454,101.421242 80.113228,116.294617 73.250465,129.934143
|
||||
C66.561974,120.254860 59.663364,110.246521 52.724716,100.266022
|
||||
C51.969223,99.179329 51.163902,97.864510 50.058342,97.349113
|
||||
C48.610638,96.674210 46.841187,96.689461 45.210133,96.407837
|
||||
C45.300655,98.152817 44.742332,100.347870 45.588940,101.568398
|
||||
C50.416821,108.528564 55.774277,115.123421 60.551853,122.115669
|
||||
C63.214252,126.012215 65.101067,130.438705 67.337395,134.626373
|
||||
C67.066689,135.064636 66.795982,135.502899 66.525269,135.941162
|
||||
C60.127300,134.387650 53.729332,132.834137 46.957455,131.166046
|
||||
z"/>
|
||||
<path fill="#F7F7F7" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M29.101347,197.210083
|
||||
C32.687489,199.349258 36.140888,202.010361 39.972271,202.773315
|
||||
C65.869553,207.930374 91.853050,207.785751 117.789062,202.866898
|
||||
C120.367584,202.377853 122.869057,201.321823 125.307068,200.293655
|
||||
C128.306778,199.028625 129.785492,197.204117 125.965691,194.203171
|
||||
C128.950974,194.203171 131.302048,194.203171 133.975403,194.203171
|
||||
C133.975403,199.587265 133.975403,205.006866 133.975403,210.790558
|
||||
C97.339554,210.790558 60.497715,210.790558 23.346436,210.790558
|
||||
C23.346436,205.364929 23.346436,199.820251 23.346436,194.115601
|
||||
C25.982605,194.115601 28.443497,194.115601 31.195951,194.115601
|
||||
C30.439959,195.124176 29.791719,195.989014 29.101347,197.210083
|
||||
z"/>
|
||||
<path fill="#F7F7F7" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M109.728516,126.729446
|
||||
C110.942009,126.107063 112.171371,125.876854 112.638802,125.153961
|
||||
C113.494293,123.830933 113.899590,122.216797 114.492622,120.724052
|
||||
C112.915543,120.371620 110.949959,119.239540 109.827286,119.798988
|
||||
C104.376648,122.515160 99.151939,125.684715 93.977409,128.619843
|
||||
C95.430588,117.357117 103.877777,109.852783 116.515198,109.278954
|
||||
C120.798874,109.084435 125.126602,109.722458 129.426620,110.072937
|
||||
C132.397888,110.315109 133.459183,111.789902 132.510712,114.812096
|
||||
C127.353615,131.244598 116.939491,142.667160 94.691971,136.287384
|
||||
C100.170006,132.791016 104.785385,129.845230 109.728516,126.729446
|
||||
z"/>
|
||||
<path fill="#DADADA" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M127.226685,243.129974
|
||||
C119.157913,245.238876 111.202492,248.238556 102.998215,249.282455
|
||||
C81.996941,251.954620 60.957745,251.531845 40.161602,247.032974
|
||||
C36.803234,246.306442 33.620003,244.770340 30.381607,243.191864
|
||||
C30.612766,242.498444 30.818136,242.221619 31.023510,241.944809
|
||||
C40.261440,243.774994 49.425594,246.440094 58.752422,247.264252
|
||||
C79.137024,249.065552 99.554466,248.974731 119.647240,244.092133
|
||||
C121.567833,243.625427 123.418419,242.870621 125.814621,242.415604
|
||||
C126.627388,242.764420 126.927032,242.947189 127.226685,243.129974
|
||||
z"/>
|
||||
<path fill="#DADADA" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M79.543045,228.333679
|
||||
C57.741947,227.340164 45.633919,224.688126 41.317654,219.788437
|
||||
C72.449089,228.657867 103.549431,227.405319 134.795914,222.552048
|
||||
C116.952873,228.631210 98.538544,229.025742 79.543045,228.333679
|
||||
z"/>
|
||||
<path fill="#F0F1F0" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M30.746689,241.637924
|
||||
C30.818136,242.221619 30.612766,242.498444 30.176378,242.902405
|
||||
C26.926825,250.379044 28.219160,252.593735 39.068638,258.157684
|
||||
C33.602814,258.157684 28.557928,258.157684 23.269331,258.157684
|
||||
C23.269331,252.579620 23.269331,247.160934 23.269331,241.331039
|
||||
C25.667660,241.331039 28.068764,241.331039 30.746689,241.637924
|
||||
z"/>
|
||||
<path fill="#F5F5F5" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M136.416870,233.103363
|
||||
C140.799637,229.973740 141.416885,223.391983 137.299469,219.085815
|
||||
C140.318726,218.356171 142.847580,217.745026 145.800522,217.031418
|
||||
C145.800522,223.322662 145.800522,228.849777 145.800522,234.627090
|
||||
C142.080200,234.627090 138.539352,234.627090 134.276489,234.627090
|
||||
C135.171326,233.931564 135.621735,233.581482 136.416870,233.103363
|
||||
z"/>
|
||||
<path fill="#F6F6F6" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M127.606003,243.176666
|
||||
C126.927032,242.947189 126.627388,242.764420 126.183372,242.360580
|
||||
C128.531265,241.652649 131.023529,241.165771 133.958069,240.592499
|
||||
C133.958069,246.760452 133.958069,252.275604 133.958069,258.078674
|
||||
C128.905533,258.078674 123.854500,258.078674 118.629097,258.078674
|
||||
C121.766220,256.379608 125.849388,255.438614 127.482864,252.913132
|
||||
C129.005188,250.559509 127.921707,246.520462 127.606003,243.176666
|
||||
z"/>
|
||||
<path fill="#F5F5F5" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M40.598701,224.796265
|
||||
C39.590347,229.892563 42.141552,232.529358 47.364601,234.665741
|
||||
C42.344856,234.665741 38.822170,234.665741 35.013187,234.665741
|
||||
C35.013187,229.080505 35.013187,223.692383 35.013187,217.571625
|
||||
C37.258606,217.737061 39.497719,217.902039 41.879658,218.077530
|
||||
C41.339489,220.731308 40.970074,222.546173 40.598701,224.796265
|
||||
z"/>
|
||||
<path fill="#0F0F0F" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M216.693359,210.354095
|
||||
C214.617416,213.736679 212.031647,214.686905 209.089539,212.408997
|
||||
C206.622116,210.498672 206.323578,207.661652 208.530258,205.526535
|
||||
C209.693359,204.401154 212.773026,203.501938 213.626099,204.189499
|
||||
C215.220612,205.474670 215.823059,207.990723 216.693359,210.354095
|
||||
z"/>
|
||||
<path fill="#0D0D0D" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M173.911514,174.328552
|
||||
C175.370041,170.713211 177.808334,168.954147 180.887527,171.127762
|
||||
C182.304138,172.127747 183.571594,175.460312 182.926712,176.727783
|
||||
C182.102051,178.348633 179.157135,179.908905 177.355637,179.680862
|
||||
C176.001541,179.509445 175.009354,176.479233 173.911514,174.328552
|
||||
z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,176 @@
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="100%" viewBox="0 0 272 272" xml:space="preserve">
|
||||
<path fill="#000000" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M155.136902,160.679138
|
||||
C157.278809,160.680267 158.944641,160.679169 160.610489,160.682465
|
||||
C170.384842,160.701797 174.996109,168.107040 170.821823,177.180923
|
||||
C169.482391,180.092529 166.838486,181.168121 163.853058,181.180237
|
||||
C150.360275,181.234955 136.865692,181.301193 123.374809,181.127029
|
||||
C118.300728,181.061523 115.912468,178.313385 115.691635,173.210464
|
||||
C115.619690,171.547882 115.625237,169.876785 115.697891,168.214081
|
||||
C115.907806,163.410278 118.293045,160.970657 123.169983,160.726395
|
||||
C125.995178,160.584885 128.832535,160.684799 131.664413,160.678467
|
||||
C138.663757,160.662842 138.695465,160.663391 138.695709,153.921509
|
||||
C138.696243,138.762436 138.604721,123.603088 138.675842,108.444443
|
||||
C138.698257,103.666214 137.328323,99.892448 133.539902,96.625702
|
||||
C127.998482,91.847336 122.946960,86.502403 117.656723,81.430153
|
||||
C116.203552,80.036858 114.652138,78.403397 112.568665,80.458054
|
||||
C110.373062,82.623283 111.793259,84.364021 113.426506,85.980011
|
||||
C118.755112,91.252296 124.116318,96.491760 129.425201,101.783760
|
||||
C130.680542,103.035110 132.424103,103.979401 132.612228,106.561836
|
||||
C127.893364,109.811699 122.497986,109.837540 117.102821,108.806168
|
||||
C107.484642,106.967522 100.466797,101.364113 95.948730,92.789185
|
||||
C92.573250,86.382790 91.004326,79.456001 90.563507,72.185219
|
||||
C90.267036,67.295349 91.692070,64.803680 96.930313,64.269653
|
||||
C109.050766,63.033997 120.945908,62.728313 131.637253,69.846550
|
||||
C140.568069,75.792656 145.335464,84.087975 145.386627,94.986328
|
||||
C145.401840,98.226059 144.835419,101.583702 146.062592,104.589165
|
||||
C147.921982,104.978363 148.578186,103.841370 149.277374,102.994545
|
||||
C159.285889,90.872841 172.893417,89.928596 187.073517,90.807846
|
||||
C189.361191,90.949684 190.578369,92.415092 191.088409,94.608749
|
||||
C194.648315,109.920135 182.619110,130.081436 167.781357,133.703094
|
||||
C164.480774,134.508728 161.163696,134.508926 157.864044,134.017654
|
||||
C156.286697,133.782822 154.327942,133.638062 153.616470,131.854263
|
||||
C152.830841,129.884521 154.644699,128.871109 155.725250,127.715118
|
||||
C160.047180,123.091515 164.444168,118.538155 168.786194,113.933151
|
||||
C169.696320,112.967918 170.607773,111.966988 171.307297,110.850159
|
||||
C172.062866,109.643883 172.120056,108.256599 171.056534,107.155266
|
||||
C169.800919,105.855003 168.232086,105.934258 167.015961,107.051399
|
||||
C160.386246,113.141541 154.196594,119.673759 148.650406,126.755341
|
||||
C147.690002,127.981613 147.935104,130.286377 147.920456,132.095398
|
||||
C147.861176,139.424438 147.907059,146.754486 147.935043,154.084061
|
||||
C147.959930,160.597031 147.972107,160.596985 155.136902,160.679138
|
||||
z"/>
|
||||
<path fill="#000000" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M161.238678,75.794922
|
||||
C157.350403,76.478325 153.863373,76.652405 150.429169,75.746155
|
||||
C145.370743,74.411263 144.910934,72.928558 148.407166,69.134872
|
||||
C153.256012,63.873497 158.177658,58.679230 163.071304,53.459194
|
||||
C163.754044,52.730907 164.513214,52.066338 165.135269,51.290699
|
||||
C166.529205,49.552586 169.071960,47.813976 166.702637,45.391987
|
||||
C164.355774,42.992954 162.466766,45.151485 160.852554,46.836220
|
||||
C156.593765,51.281097 152.398941,55.787292 148.181381,60.271645
|
||||
C145.696976,62.913208 143.524002,65.879295 140.070541,68.194733
|
||||
C135.590454,62.374969 135.733047,55.992954 137.262512,49.648361
|
||||
C139.764725,39.268448 146.817566,32.782806 156.448410,28.863106
|
||||
C164.175934,25.718050 172.290604,25.364687 180.496155,25.685896
|
||||
C182.846786,25.777910 184.166138,26.991177 184.890427,29.065540
|
||||
C188.810059,40.291386 182.781891,61.710209 173.527847,69.622910
|
||||
C170.043442,72.602280 166.133514,74.681480 161.238678,75.794922
|
||||
z"/>
|
||||
<path fill="#000000" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M51.179115,191.885223
|
||||
C52.438942,194.277176 53.616596,196.295410 54.623913,198.395355
|
||||
C55.896088,201.047440 56.155109,203.724594 53.063660,205.251877
|
||||
C50.103329,206.714401 48.377621,204.667572 47.061089,202.381729
|
||||
C39.580849,189.394073 34.563553,175.504913 32.789696,160.616119
|
||||
C28.650021,125.870003 38.647842,95.437637 61.659519,69.286713
|
||||
C73.564240,55.757954 88.284668,46.261253 105.390541,40.508171
|
||||
C108.395813,39.497429 111.582947,38.681858 112.973595,42.529522
|
||||
C114.333626,46.292507 111.620026,47.862175 108.529938,48.975166
|
||||
C70.687431,62.605324 49.556923,90.376961 41.919552,128.962769
|
||||
C37.616646,150.702087 41.164444,171.677353 51.179115,191.885223
|
||||
z"/>
|
||||
<path fill="#000000" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M239.784363,83.296997
|
||||
C264.242523,123.717094 265.615234,164.176544 241.721130,204.974319
|
||||
C240.208130,207.557709 238.368149,210.762985 234.760773,208.515533
|
||||
C231.135925,206.257187 232.690842,203.056580 234.495148,200.232864
|
||||
C264.442444,153.366043 249.966141,89.427719 202.605209,58.056751
|
||||
C199.811401,56.206192 196.180557,54.205814 198.804718,50.299259
|
||||
C201.566254,46.188240 204.964035,48.804581 207.878448,50.801598
|
||||
C220.533203,59.472889 231.114990,70.195419 239.784363,83.296997
|
||||
z"/>
|
||||
<path fill="#000000" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M120.025833,208.540024
|
||||
C128.534424,207.033951 136.704941,208.034149 144.845612,207.802826
|
||||
C150.673035,207.637222 156.509705,207.688675 162.340363,207.773239
|
||||
C169.095840,207.871185 171.300308,210.143478 171.367859,216.815277
|
||||
C171.379669,217.981476 171.389923,219.148911 171.351913,220.314178
|
||||
C171.180527,225.568863 168.980942,228.144760 163.786972,228.203720
|
||||
C150.292038,228.356934 136.793213,228.336533 123.297897,228.196335
|
||||
C118.546494,228.146973 115.853645,225.400116 115.759140,220.681458
|
||||
C115.671776,216.319031 114.510735,211.446625 120.025833,208.540024
|
||||
z"/>
|
||||
<path fill="#000000" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M123.411064,231.713181
|
||||
C136.857925,231.699600 149.848877,231.655319 162.839371,231.711792
|
||||
C169.423615,231.740402 171.304550,233.894745 171.353775,241.072342
|
||||
C171.411438,249.479706 169.577438,252.022110 162.830200,252.081665
|
||||
C150.007080,252.194885 137.181580,252.166138 124.358177,252.061539
|
||||
C118.758484,252.015884 116.075554,249.512909 115.710770,244.514389
|
||||
C115.053650,235.510147 116.381241,233.166168 123.411064,231.713181
|
||||
z"/>
|
||||
<path fill="#000000" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M137.002441,204.323700
|
||||
C132.339676,204.318451 128.174820,204.381058 124.013191,204.291885
|
||||
C117.847504,204.159760 115.688408,201.588760 115.669815,194.554657
|
||||
C115.650574,187.275452 117.613632,184.748840 123.776802,184.707001
|
||||
C137.099121,184.616547 150.422882,184.618866 163.745255,184.706085
|
||||
C169.066559,184.740906 171.188782,187.015503 171.383377,192.372208
|
||||
C171.431702,193.702805 171.430328,195.037506 171.391434,196.368561
|
||||
C171.225891,202.032608 169.125977,204.235840 163.481277,204.295456
|
||||
C154.822311,204.386917 146.161713,204.322144 137.002441,204.323700
|
||||
z"/>
|
||||
<path fill="#000000" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M226.648254,245.322098
|
||||
C225.960587,250.168289 222.968353,252.024597 218.928391,252.065079
|
||||
C206.796814,252.186630 194.659286,252.261200 182.531631,252.001785
|
||||
C176.380005,251.870193 174.516678,248.845230 174.654755,240.781891
|
||||
C174.758606,234.717667 177.157455,231.841934 182.807098,231.773193
|
||||
C194.606125,231.629623 206.409836,231.608932 218.208267,231.775299
|
||||
C225.270309,231.874863 227.160767,235.013565 226.648254,245.322098
|
||||
z"/>
|
||||
<path fill="#000000" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M64.397949,249.405182
|
||||
C61.792465,245.211655 62.394962,240.994888 63.154949,236.958160
|
||||
C63.771866,233.681320 66.432693,231.853928 69.727989,231.820724
|
||||
C81.546158,231.701691 93.368942,231.629852 105.184380,231.845993
|
||||
C110.134293,231.936554 112.149864,234.465271 112.356758,239.552567
|
||||
C112.417633,241.049225 112.442459,242.548737 112.432884,244.046631
|
||||
C112.399452,249.277054 110.274918,251.922318 105.025696,252.022171
|
||||
C93.543213,252.240601 82.053345,252.101715 70.566780,252.048523
|
||||
C68.353378,252.038284 66.308258,251.333664 64.397949,249.405182
|
||||
z"/>
|
||||
<path fill="#000000" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M219.431229,208.965515
|
||||
C225.958908,210.830124 227.898087,214.429626 226.728973,221.728302
|
||||
C225.951477,226.582169 223.546860,228.307251 217.347839,228.320145
|
||||
C206.046173,228.343658 194.744293,228.349930 183.442688,228.310532
|
||||
C177.071014,228.288315 174.831345,226.172348 174.658157,220.208786
|
||||
C174.423798,212.138199 176.485138,209.095779 183.093033,208.980026
|
||||
C195.055496,208.770508 207.024353,208.927063 219.431229,208.965515
|
||||
z"/>
|
||||
<path fill="#000000" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M111.844833,201.835693
|
||||
C110.072998,204.754562 107.653206,205.427658 104.805984,205.415924
|
||||
C93.317833,205.368607 81.828857,205.454147 70.341309,205.357117
|
||||
C65.234276,205.313980 62.986954,202.859207 62.709557,197.543106
|
||||
C62.299370,189.682190 64.093681,186.175278 69.667580,186.022751
|
||||
C81.644623,185.694992 93.639923,185.765549 105.621117,186.004898
|
||||
C109.740837,186.087204 112.488106,188.470795 112.355774,193.052628
|
||||
C112.274567,195.864578 112.966019,198.708054 111.844833,201.835693
|
||||
z"/>
|
||||
<path fill="#000000" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M91.995438,208.848816
|
||||
C96.652344,208.868423 100.821526,208.703552 104.965866,208.952423
|
||||
C111.063820,209.318619 112.701241,212.094238 112.440933,220.945969
|
||||
C112.290970,226.045120 110.028008,228.263199 104.531395,228.300446
|
||||
C93.387711,228.375977 82.243011,228.358887 71.099030,228.309952
|
||||
C65.162483,228.283875 62.889286,226.053818 62.693813,220.323608
|
||||
C62.422928,212.382645 64.344727,209.306152 70.543953,209.005508
|
||||
C77.514305,208.667435 84.511238,208.877655 91.995438,208.848816
|
||||
z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.5 KiB |
@@ -0,0 +1,117 @@
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="100%" viewBox="0 0 272 272" xml:space="preserve">
|
||||
<path fill="#000000" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M158.095673,182.380066
|
||||
C126.610283,191.384659 96.643555,174.322510 90.272842,143.623428
|
||||
C86.729164,126.547180 91.474091,110.665001 99.303589,95.568748
|
||||
C102.691605,89.036255 107.036667,83.141510 112.035118,77.728897
|
||||
C113.319054,76.338585 114.478966,75.388702 116.701714,75.625420
|
||||
C133.262619,77.389214 149.845749,77.351952 166.410599,75.630684
|
||||
C168.082947,75.456909 169.365524,75.689964 170.434189,76.920845
|
||||
C188.195648,97.378609 198.687729,120.337639 191.817703,147.885025
|
||||
C187.482712,165.267456 175.306259,176.339905 158.095673,182.380066
|
||||
M126.231583,107.753784
|
||||
C125.160301,109.434593 123.945847,111.042679 123.047516,112.811333
|
||||
C121.172157,116.503578 120.869102,121.539383 114.950623,121.535866
|
||||
C114.140930,121.535378 113.606102,122.523849 113.594505,123.414284
|
||||
C113.579636,124.556854 114.301460,125.332832 115.346672,125.540749
|
||||
C117.332375,125.935753 119.201553,126.000473 119.355324,128.904938
|
||||
C119.523094,132.073959 117.237221,132.039062 115.356735,132.952316
|
||||
C113.400711,133.902267 112.602074,136.885330 114.736053,136.971802
|
||||
C121.555397,137.248108 121.382263,142.996201 123.526703,146.812012
|
||||
C131.080215,160.252777 149.129044,164.126953 161.057465,154.700378
|
||||
C162.951828,153.203339 164.862213,151.663467 162.898254,148.931641
|
||||
C161.186295,146.550354 159.255936,145.720459 156.605408,147.752914
|
||||
C153.878845,149.843643 150.605209,150.630569 147.240005,150.839050
|
||||
C138.989349,151.350266 132.353531,145.841476 132.272171,138.283798
|
||||
C136.637039,137.121429 141.143677,137.949417 145.577881,137.659866
|
||||
C147.349060,137.544235 149.153610,137.140396 149.210190,135.057495
|
||||
C149.269226,132.884872 147.387283,132.491898 145.657013,132.448883
|
||||
C142.326309,132.366043 138.990448,132.474701 135.660797,132.371796
|
||||
C133.313263,132.299240 130.178772,133.178238 130.125732,129.237137
|
||||
C130.069077,125.025673 133.362106,125.817207 135.901245,125.761330
|
||||
C139.731903,125.677055 143.566147,125.769890 147.398010,125.720673
|
||||
C149.218506,125.697289 151.449768,125.842491 151.491653,123.239723
|
||||
C151.534973,120.547668 149.333618,120.797882 147.484360,120.818802
|
||||
C143.652573,120.862152 139.815933,121.044899 135.990250,120.912178
|
||||
C131.524597,120.757256 130.756897,119.201927 132.896515,115.196129
|
||||
C137.040604,107.437553 147.037689,104.908760 155.482910,109.712708
|
||||
C158.105911,111.204773 159.937332,112.218033 162.002380,108.853325
|
||||
C164.154938,105.346039 162.393082,103.785217 159.748962,102.092812
|
||||
C149.247238,95.371048 136.016663,97.391907 126.231583,107.753784
|
||||
z"/>
|
||||
<path fill="#000000" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M62.010902,209.220154
|
||||
C58.180298,209.209946 54.840157,209.334717 51.515400,209.162521
|
||||
C43.268307,208.735428 37.784286,203.304825 37.760422,195.008148
|
||||
C37.658302,159.509567 37.665783,124.010384 37.766045,88.511795
|
||||
C37.786678,81.207672 42.718807,75.343628 50.032757,75.150185
|
||||
C66.846870,74.705467 83.681374,75.029007 100.507545,75.065941
|
||||
C100.781677,75.066544 101.055115,75.386787 101.326675,75.561401
|
||||
C99.334236,83.089569 95.270798,86.398506 88.019554,86.402924
|
||||
C77.519890,86.409325 67.019814,86.364708 56.520706,86.444679
|
||||
C50.334618,86.491791 49.281921,87.600029 49.279175,93.801788
|
||||
C49.265015,125.800789 49.268635,157.799820 49.294022,189.798813
|
||||
C49.298950,196.011230 50.044891,196.789673 56.165073,196.791351
|
||||
C112.996635,196.806976 169.828201,196.802826 226.659775,196.776962
|
||||
C233.181854,196.774002 233.983032,196.023315 233.994247,189.651611
|
||||
C234.050583,157.652756 234.066727,125.653786 234.044434,93.654892
|
||||
C234.040115,87.463776 232.940948,86.444275 226.664841,86.417778
|
||||
C216.832001,86.376266 206.923157,85.653069 197.188080,86.629730
|
||||
C188.285980,87.522827 184.842407,82.422295 181.453766,76.191666
|
||||
C182.819046,74.283363 184.632523,74.877281 186.192474,74.872559
|
||||
C200.858536,74.828194 215.525238,74.783653 230.190857,74.879097
|
||||
C240.413727,74.945625 245.392822,80.078781 245.400253,90.396751
|
||||
C245.424973,124.728989 245.418106,159.061249 245.408508,193.393509
|
||||
C245.405472,204.258896 240.451096,209.215683 229.501266,209.218689
|
||||
C173.836960,209.233932 118.172668,209.222260 62.010902,209.220154
|
||||
z"/>
|
||||
<path fill="#000000" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M138.000488,51.254517
|
||||
C130.706497,51.574547 123.870560,50.576160 117.108437,51.898903
|
||||
C114.838310,52.342964 114.302711,50.657059 113.677750,49.101814
|
||||
C112.066666,45.092583 110.382202,41.109913 108.888763,37.057041
|
||||
C106.838936,31.494238 109.161499,28.022289 115.233788,27.800480
|
||||
C120.713921,27.600300 126.206779,27.744429 131.694031,27.744444
|
||||
C143.333786,27.744480 154.973969,27.689842 166.613144,27.773260
|
||||
C174.550446,27.830147 176.724197,31.167881 173.766769,38.585972
|
||||
C172.107651,42.747543 171.300217,47.920856 168.307343,50.693470
|
||||
C165.181580,53.589180 159.844284,50.870831 155.455292,51.158329
|
||||
C149.825714,51.527084 144.153931,51.251602 138.000488,51.254517
|
||||
z"/>
|
||||
<path fill="#000000" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M167.239594,232.585693
|
||||
C171.880035,232.597687 176.044556,232.607880 180.209076,232.618408
|
||||
C184.122040,232.628311 185.336441,234.898361 185.331696,238.451355
|
||||
C185.326782,242.133560 185.340134,245.833282 180.434769,246.146805
|
||||
C176.286346,246.411942 172.109802,246.253571 167.945709,246.254623
|
||||
C146.956024,246.259949 125.966293,246.277267 104.976669,246.241623
|
||||
C99.256363,246.231918 97.644928,244.372543 97.761223,238.667953
|
||||
C97.844475,234.584686 99.583359,232.600510 103.837791,232.582825
|
||||
C124.813271,232.495651 145.787750,231.346375 167.239594,232.585693
|
||||
z"/>
|
||||
<path fill="#000000" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M122.387810,72.159325
|
||||
C115.712921,71.125893 113.374664,68.562798 113.702881,63.131916
|
||||
C114.005707,58.121067 116.874458,55.728867 122.914513,55.703850
|
||||
C135.381210,55.652218 147.848450,55.648373 160.315125,55.703449
|
||||
C166.656784,55.731464 169.281540,58.133209 169.339828,63.679066
|
||||
C169.401382,69.537247 166.730423,72.138657 160.241852,72.172119
|
||||
C147.775345,72.236420 135.308167,72.173775 122.387810,72.159325
|
||||
z"/>
|
||||
<path fill="#000000" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M120.809006,227.599243
|
||||
C118.412361,224.676559 118.964920,221.665634 119.362236,218.712006
|
||||
C119.728920,215.986038 121.380112,214.677689 124.149628,214.681686
|
||||
C135.792786,214.698532 147.436096,214.654755 159.079086,214.701065
|
||||
C163.835938,214.720001 164.130920,218.203552 164.120667,221.685699
|
||||
C164.111938,224.651352 164.506165,228.244736 160.036530,228.225891
|
||||
C147.079773,228.171280 134.114151,228.831680 120.809006,227.599243
|
||||
z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.6 KiB |
@@ -0,0 +1,126 @@
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="100%" viewBox="0 0 280 280" xml:space="preserve">
|
||||
|
||||
<path fill="#070807" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M187.971039,190.091797
|
||||
C152.902359,195.179504 116.612679,164.356689 116.679649,129.598419
|
||||
C127.117012,128.359924 134.245438,122.900650 135.840607,112.122513
|
||||
C137.283142,102.375740 132.340759,95.629890 124.045776,91.114349
|
||||
C137.091721,68.691589 166.983429,57.949982 193.945023,65.843903
|
||||
C222.404404,74.176361 241.290237,101.065086 239.938477,131.327225
|
||||
C238.647232,160.235184 217.664749,184.138168 187.971039,190.091797
|
||||
M192.006790,118.220116
|
||||
C192.890167,114.965401 191.291931,114.088799 188.338959,114.125633
|
||||
C181.392471,114.212296 174.444153,114.153404 167.389969,114.153404
|
||||
C171.055756,100.658607 184.374985,95.492004 196.312607,103.018608
|
||||
C200.344269,105.560539 202.278244,104.953972 204.540314,101.170326
|
||||
C206.756897,97.462776 207.794266,94.819969 203.159561,91.803673
|
||||
C188.022903,81.952621 167.564423,86.023033 158.041458,101.406822
|
||||
C155.636673,105.291634 154.078064,109.700249 152.237259,113.639900
|
||||
C149.305481,115.551949 143.944183,111.451340 143.097900,117.960587
|
||||
C142.196609,124.892952 148.040543,122.300301 150.784882,123.424301
|
||||
C150.784882,125.504219 150.784882,127.232620 150.784882,128.859787
|
||||
C147.477676,129.490082 143.294373,127.501045 143.078308,133.119308
|
||||
C142.785522,140.732498 149.038208,136.619934 152.003799,138.336273
|
||||
C153.405350,141.869919 154.524139,145.549103 156.255508,148.912994
|
||||
C165.699112,167.260925 190.563416,171.906555 205.565750,158.091599
|
||||
C206.857025,156.902527 208.175705,154.180283 207.668991,152.872635
|
||||
C205.494232,147.260330 201.331619,146.248383 196.298904,149.456482
|
||||
C195.044235,150.256256 193.697800,150.965866 192.308395,151.492126
|
||||
C179.507553,156.340744 167.636917,147.415588 167.535217,137.916168
|
||||
C173.359207,137.916168 179.185913,137.789764 185.004227,137.956329
|
||||
C189.621704,138.088516 189.425995,134.424988 189.768204,131.766724
|
||||
C190.212418,128.315857 187.178787,129.078751 185.174057,129.059280
|
||||
C178.559830,128.994995 171.944565,129.035049 164.994614,129.035049
|
||||
C165.126587,126.736664 165.225037,125.022156 165.335968,123.090248
|
||||
C172.647888,123.090248 179.465408,122.945946 186.272171,123.154068
|
||||
C189.445282,123.251083 191.441925,122.428177 192.006790,118.220116
|
||||
z"/>
|
||||
<path fill="#080808" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M131.368011,176.708160
|
||||
C132.841904,178.086960 134.053574,179.220016 135.586899,180.653870
|
||||
C125.929825,189.666458 114.656593,194.137970 101.822441,192.323410
|
||||
C83.712830,189.763016 65.750084,186.163834 47.546059,182.977509
|
||||
C47.384335,181.477203 47.162159,180.371124 47.161064,179.264801
|
||||
C47.139774,157.769257 47.213634,136.273224 47.066685,114.778671
|
||||
C47.045330,111.654861 48.277222,110.408775 50.805054,108.764816
|
||||
C65.959984,98.908966 82.433983,95.545265 100.266533,97.288536
|
||||
C105.713516,97.821022 111.259018,97.264038 116.754372,97.403831
|
||||
C123.845337,97.584221 128.936874,102.561707 129.070221,109.203522
|
||||
C129.208694,116.100410 124.098434,121.435577 116.836906,121.605301
|
||||
C109.509109,121.776566 102.173920,121.607285 94.842476,121.658981
|
||||
C91.943604,121.679420 88.133774,120.891609 88.003433,125.323174
|
||||
C87.870049,129.858032 91.675873,129.147583 94.598854,129.174789
|
||||
C99.560677,129.221008 104.523239,129.187500 109.758858,129.187500
|
||||
C110.484703,148.119644 117.856071,163.649200 131.368011,176.708160
|
||||
z"/>
|
||||
<path fill="#0B0B0B" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M205.084167,217.108215
|
||||
C191.667587,222.178726 178.104065,222.815628 164.362610,222.268158
|
||||
C158.788757,222.046097 153.198669,222.231506 147.061096,222.231506
|
||||
C148.675461,223.966309 150.673157,225.186279 151.122467,226.829590
|
||||
C151.688950,228.901443 151.745026,231.910461 150.585266,233.387909
|
||||
C149.754471,234.446320 145.769440,234.570755 144.567810,233.527237
|
||||
C139.553329,229.172607 134.801025,224.433395 130.558746,219.331207
|
||||
C129.517853,218.079361 129.919052,214.224823 131.117844,212.772278
|
||||
C135.025345,208.037750 139.364746,203.568100 144.077606,199.644760
|
||||
C145.521118,198.443069 149.295181,198.405273 150.949814,199.469864
|
||||
C153.538681,201.135498 152.944687,204.175629 150.776672,206.377106
|
||||
C149.263092,207.914017 147.501053,209.206238 145.849991,210.607758
|
||||
C146.008224,211.095657 146.166473,211.583557 146.324707,212.071457
|
||||
C149.687134,212.071457 153.049576,212.064163 156.411987,212.072754
|
||||
C170.206345,212.107941 184.004166,213.086563 197.575531,209.064972
|
||||
C232.539062,198.704300 257.440094,164.484680 257.108368,126.779465
|
||||
C256.785187,90.051170 232.634598,58.170982 197.741547,48.693874
|
||||
C192.794434,47.350220 187.634338,46.635654 182.523529,46.101070
|
||||
C178.753525,45.706730 175.693451,44.772949 175.987289,40.420597
|
||||
C176.266968,36.278053 179.432510,35.791481 182.942886,35.902050
|
||||
C220.106506,37.072571 253.860901,64.864349 264.018494,102.727287
|
||||
C276.719360,150.070374 251.400696,199.480026 205.084167,217.108215
|
||||
z"/>
|
||||
<path fill="#0D0D0D" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M40.745705,141.000122
|
||||
C40.743919,155.974396 40.759007,170.448669 40.732208,184.922882
|
||||
C40.720936,191.011368 39.145897,192.582489 33.064716,192.665482
|
||||
C29.737946,192.710876 26.409994,192.663467 23.082607,192.671646
|
||||
C18.541838,192.682816 16.416592,190.415253 16.421246,185.888489
|
||||
C16.447411,160.433868 16.431442,134.979187 16.433918,109.524536
|
||||
C16.434351,105.065117 18.491638,102.712173 23.138309,102.830185
|
||||
C26.796106,102.923088 30.460051,102.923637 34.117825,102.830276
|
||||
C38.789894,102.711029 40.755470,105.090736 40.746799,109.555191
|
||||
C40.726768,119.870125 40.743710,130.185135 40.745705,141.000122
|
||||
z"/>
|
||||
<path fill="#F8F8F8" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M191.926239,118.646698
|
||||
C191.441925,122.428177 189.445282,123.251083 186.272171,123.154068
|
||||
C179.465408,122.945946 172.647888,123.090248 165.335968,123.090248
|
||||
C165.225037,125.022156 165.126587,126.736664 164.994614,129.035049
|
||||
C171.944565,129.035049 178.559830,128.994995 185.174057,129.059280
|
||||
C187.178787,129.078751 190.212418,128.315857 189.768204,131.766724
|
||||
C189.425995,134.424988 189.621704,138.088516 185.004227,137.956329
|
||||
C179.185913,137.789764 173.359207,137.916168 167.535217,137.916168
|
||||
C167.636917,147.415588 179.507553,156.340744 192.308395,151.492126
|
||||
C193.697800,150.965866 195.044235,150.256256 196.298904,149.456482
|
||||
C201.331619,146.248383 205.494232,147.260330 207.668991,152.872635
|
||||
C208.175705,154.180283 206.857025,156.902527 205.565750,158.091599
|
||||
C190.563416,171.906555 165.699112,167.260925 156.255508,148.912994
|
||||
C154.524139,145.549103 153.405350,141.869919 152.009094,138.337067
|
||||
C149.038208,136.619934 142.785522,140.732498 143.078308,133.119308
|
||||
C143.294373,127.501045 147.477676,129.490082 150.784882,128.859787
|
||||
C150.784882,127.232620 150.784882,125.504219 150.784882,123.424301
|
||||
C148.040543,122.300301 142.196609,124.892952 143.097900,117.960587
|
||||
C143.944183,111.451340 149.305481,115.551949 152.237259,113.639900
|
||||
C154.078064,109.700249 155.636673,105.291634 158.041458,101.406822
|
||||
C167.564423,86.023033 188.022903,81.952621 203.159561,91.803673
|
||||
C207.794266,94.819969 206.756897,97.462776 204.540314,101.170326
|
||||
C202.278244,104.953972 200.344269,105.560539 196.312607,103.018608
|
||||
C184.374985,95.492004 171.055756,100.658607 167.389969,114.153404
|
||||
C174.444153,114.153404 181.392471,114.212296 188.338959,114.125633
|
||||
C191.291931,114.088799 192.890167,114.965401 191.926239,118.646698
|
||||
z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.4 KiB |
@@ -0,0 +1,102 @@
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="100%" viewBox="0 0 280 280" xml:space="preserve">
|
||||
|
||||
<path fill="#030303" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M115.616562,168.830780
|
||||
C132.714386,168.828491 149.373917,168.816940 166.033401,168.838806
|
||||
C171.940826,168.846558 172.514847,169.627930 170.992630,175.261047
|
||||
C168.221313,185.516632 168.221313,185.522583 157.512436,185.523422
|
||||
C142.685577,185.524597 127.858727,185.515701 113.031868,185.511246
|
||||
C111.581741,185.510818 110.131607,185.511200 108.321762,185.511200
|
||||
C108.321762,189.954895 108.321762,193.870560 108.321762,198.319473
|
||||
C110.248947,198.414200 112.004524,198.573853 113.760231,198.575256
|
||||
C128.420578,198.586868 143.080917,198.565323 157.741272,198.562576
|
||||
C163.942520,198.561401 164.691132,199.422226 163.041290,205.163025
|
||||
C159.596893,217.147980 161.459702,215.037201 149.824341,215.141876
|
||||
C137.704086,215.250916 125.582077,215.166199 113.392197,215.166199
|
||||
C116.618881,228.037567 131.322723,239.760818 145.411438,241.013199
|
||||
C157.246658,242.065262 167.950821,239.474274 176.787338,231.178192
|
||||
C180.096298,228.071625 182.787842,228.421463 185.715134,231.578354
|
||||
C188.657974,234.751999 191.709793,237.835251 194.859955,240.803070
|
||||
C198.220047,243.968704 197.726044,246.724304 194.512115,249.616882
|
||||
C183.296707,259.710968 169.990616,264.954010 155.113541,265.822815
|
||||
C121.534111,267.783661 95.710289,250.030228 84.883812,218.186813
|
||||
C84.438538,216.877136 82.308311,215.484192 80.826530,215.313156
|
||||
C77.209366,214.895630 73.511391,215.213287 69.847084,215.158783
|
||||
C66.521782,215.109344 65.403259,213.371826 66.294411,210.233505
|
||||
C66.339790,210.073700 66.409302,209.920578 66.451912,209.760178
|
||||
C69.206276,199.393036 69.206131,199.392990 81.000824,198.202835
|
||||
C81.000824,194.166153 81.000824,190.101913 81.000824,185.557159
|
||||
C77.234695,185.557159 73.617836,185.611282 70.003395,185.540939
|
||||
C66.805969,185.478714 65.385887,183.945404 66.355713,180.688431
|
||||
C66.450417,180.370377 66.608177,180.071335 66.706367,179.754074
|
||||
C70.480980,167.557663 67.100960,168.698807 80.645340,168.933929
|
||||
C84.105324,168.994003 84.595108,167.287949 85.445755,164.716278
|
||||
C102.186722,114.105354 158.153519,108.784904 187.734283,128.903076
|
||||
C199.955643,137.214951 200.095505,138.142761 189.654221,148.477844
|
||||
C189.535812,148.595032 189.418137,148.712952 189.299408,148.829819
|
||||
C182.020966,155.994232 182.020966,155.994232 173.803528,150.177353
|
||||
C153.392258,135.728836 124.968384,142.911331 114.379700,165.285538
|
||||
C113.941444,166.211594 110.831482,168.631775 115.616562,168.830780
|
||||
z"/>
|
||||
<path fill="#030303" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M215.938721,69.927177
|
||||
C220.227676,63.889359 225.814880,62.494019 232.654907,62.814648
|
||||
C242.282272,63.265945 251.951614,62.810158 261.603119,62.762669
|
||||
C268.092987,62.730732 268.941711,63.542873 268.979248,69.797531
|
||||
C269.047119,81.113037 269.311981,92.434540 269.002838,103.740082
|
||||
C268.925720,106.560387 267.595764,109.858849 265.759949,112.017677
|
||||
C261.187134,117.395042 255.991745,122.242950 250.142471,128.228271
|
||||
C250.142471,117.139091 250.142471,107.439148 250.142471,96.764252
|
||||
C248.489868,98.114250 247.494354,98.794449 246.656555,99.632347
|
||||
C237.832214,108.457840 228.932266,117.211647 220.286819,126.209724
|
||||
C217.601227,129.004837 215.614258,129.407700 212.970306,126.431480
|
||||
C210.542664,123.698784 208.012985,121.025314 205.284119,118.600510
|
||||
C202.584137,116.201378 203.017319,114.435966 205.430420,112.100334
|
||||
C215.339020,102.509789 225.108673,92.775681 235.813919,82.222458
|
||||
C224.770737,82.222458 214.973511,82.222458 205.176300,82.222458
|
||||
C204.735153,81.622482 204.294022,81.022499 203.852875,80.422523
|
||||
C207.797699,77.008026 211.742538,73.593529 215.938721,69.927177
|
||||
z"/>
|
||||
<path fill="#030303" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M52.223427,82.021805
|
||||
C49.764458,82.019341 47.771915,82.019341 44.973446,82.019341
|
||||
C46.225830,83.470085 47.051693,84.553185 48.004135,85.510323
|
||||
C56.692253,94.241341 65.328072,103.026741 74.150505,111.620369
|
||||
C76.868561,114.267952 77.060066,116.116295 74.345703,119.003006
|
||||
C63.730991,130.291626 63.835194,130.358963 52.962822,119.371391
|
||||
C45.621754,111.952530 38.228100,104.585701 30.019478,96.354828
|
||||
C30.019478,107.225266 30.019478,117.026817 30.019478,127.639351
|
||||
C23.653513,121.414146 17.900156,115.981827 12.431064,110.277000
|
||||
C11.331538,109.130081 10.872101,106.949936 10.864596,105.242889
|
||||
C10.810435,92.921791 10.995296,80.599846 10.997477,68.278152
|
||||
C10.998140,64.525322 12.639067,62.729805 16.388678,62.756905
|
||||
C29.043234,62.848362 41.698658,62.866459 54.351711,63.052021
|
||||
C55.722702,63.072132 57.425102,63.641075 58.387566,64.565666
|
||||
C64.084976,70.038918 69.609268,75.692398 75.929405,82.024261
|
||||
C67.498825,82.024261 60.094345,82.024261 52.223427,82.021805
|
||||
z"/>
|
||||
<path fill="#030303" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M127.301056,51.131123
|
||||
C120.976631,57.483810 114.925407,63.584396 108.104401,70.461044
|
||||
C108.104401,61.445499 107.969879,53.526920 108.216934,45.620270
|
||||
C108.266846,44.022911 109.450645,42.169266 110.638992,40.946056
|
||||
C118.997307,32.342503 127.507576,23.886814 135.937531,15.352496
|
||||
C138.502090,12.756181 140.996552,12.447431 143.684967,15.153491
|
||||
C152.373886,23.899422 161.155655,32.555004 169.717728,41.422966
|
||||
C170.957413,42.706936 171.737442,44.946159 171.793442,46.772057
|
||||
C172.022736,54.250366 171.889160,61.739807 171.889160,69.220604
|
||||
C165.134216,62.229801 158.423584,55.284851 151.712936,48.339905
|
||||
C151.225449,48.647831 150.737976,48.955761 150.250488,49.263691
|
||||
C150.160370,50.632038 149.994415,52.000252 149.992004,53.368755
|
||||
C149.970444,65.534180 149.842987,77.701988 150.053162,89.863808
|
||||
C150.122375,93.869949 148.656921,95.013756 144.820389,95.049957
|
||||
C129.947159,95.190323 129.955978,95.293648 129.975876,80.545113
|
||||
C129.989777,70.230980 129.978561,59.916821 129.978561,48.896011
|
||||
C128.903702,49.782528 128.238968,50.330772 127.301056,51.131123
|
||||
z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 68 KiB |
@@ -0,0 +1,437 @@
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="100%" viewBox="0 0 280 280" xml:space="preserve">
|
||||
|
||||
<path fill="#040404" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M15.210104,65.981293
|
||||
C15.312501,64.591057 15.414898,63.200821 15.517828,61.088524
|
||||
C15.426287,59.905792 15.334212,59.445126 15.242138,58.984455
|
||||
C17.873795,48.346745 21.808460,44.586880 31.917931,43.750896
|
||||
C33.103672,43.681213 33.555176,43.611732 34.006683,43.542252
|
||||
C36.162319,43.622040 38.317932,43.770916 40.473591,43.771465
|
||||
C86.911095,43.783318 133.348663,43.750607 179.785965,43.848362
|
||||
C182.499069,43.854073 185.209991,44.888992 188.441895,45.864624
|
||||
C189.596527,46.967026 190.231232,47.649754 190.865936,48.332481
|
||||
C191.347931,51.352871 191.829926,54.373268 192.420792,58.075916
|
||||
C190.742691,57.716309 189.870026,57.529305 188.250854,57.207237
|
||||
C186.336105,57.065029 185.167892,57.057884 183.540741,56.948952
|
||||
C132.395752,56.847168 81.709724,56.847168 31.135298,56.847168
|
||||
C30.773939,59.212589 30.506683,60.962025 30.099171,63.629578
|
||||
C40.890221,63.629578 51.443180,63.629578 62.365829,63.727013
|
||||
C63.491627,63.759560 64.247734,63.694668 65.462952,63.727570
|
||||
C81.280090,63.759804 96.638107,63.694241 112.365799,63.726280
|
||||
C113.491600,63.758972 114.247726,63.694069 115.462952,63.726994
|
||||
C137.948975,63.761604 159.975876,63.698380 182.331787,63.731628
|
||||
C183.108688,63.763706 183.556564,63.699310 184.004456,63.634914
|
||||
C190.555420,63.570465 196.813843,64.224106 201.084503,71.076759
|
||||
C201.975403,73.453110 202.574036,75.226929 203.172668,77.000740
|
||||
C203.122787,84.024437 203.072906,91.048126 202.565308,98.643120
|
||||
C186.493301,99.225655 170.878281,99.150085 155.264938,99.278084
|
||||
C144.516846,99.366196 136.585281,106.751511 136.175476,116.947098
|
||||
C135.975510,121.922020 135.981155,126.920120 136.204544,131.893768
|
||||
C136.568054,139.987091 142.390503,147.066452 149.956894,148.903381
|
||||
C151.649017,149.314178 153.391983,149.515579 153.844849,149.594009
|
||||
C150.928452,158.560776 148.092514,167.280121 145.130493,176.360840
|
||||
C145.010056,177.481461 145.015717,178.240692 144.863297,179.442352
|
||||
C144.802216,184.256516 144.899200,188.628265 144.860291,193.086334
|
||||
C144.724396,193.172668 144.800980,193.485397 144.346191,193.504669
|
||||
C116.908478,193.542435 89.925507,193.536133 62.942661,193.592377
|
||||
C52.961884,193.613190 42.981415,193.781113 33.000805,193.881958
|
||||
C32.552891,193.793549 32.104980,193.705139 31.032646,193.551025
|
||||
C29.272118,193.332687 28.136013,193.180038 26.999903,193.027405
|
||||
C26.308954,192.653946 25.618008,192.280472 24.505367,191.643753
|
||||
C23.590258,191.164581 23.096842,190.948639 22.603426,190.732712
|
||||
C17.360439,186.192871 15.391938,180.488312 15.474957,173.551117
|
||||
C15.696883,155.006393 15.521030,136.456924 15.502221,117.078590
|
||||
C15.400163,115.159042 15.299127,114.070244 15.198092,112.981438
|
||||
C15.303808,99.290764 15.409524,85.600082 15.515821,71.064285
|
||||
C15.414304,68.806534 15.312203,67.393913 15.210104,65.981293
|
||||
z"/>
|
||||
<path fill="#060606" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M226.939621,132.164047
|
||||
C235.796722,136.294861 243.915970,141.436447 250.583954,149.607712
|
||||
C253.268845,153.558838 255.545227,157.028000 257.821625,160.497147
|
||||
C264.319916,175.893066 265.648438,191.442810 259.138519,207.196609
|
||||
C258.195465,209.478745 257.039276,211.672806 255.628296,214.439606
|
||||
C254.016525,216.925964 252.758133,218.880341 251.499725,220.834686
|
||||
C250.078217,222.359161 248.656723,223.883636 246.854156,225.800720
|
||||
C245.978790,226.786591 245.484512,227.379807 244.990219,227.973022
|
||||
C235.502563,235.657700 225.063965,241.214294 212.003433,242.511322
|
||||
C210.886246,242.723969 210.443192,242.837326 210.000153,242.950684
|
||||
C208.280914,242.883255 206.561661,242.815826 204.069366,242.687042
|
||||
C202.538971,242.620331 201.781616,242.615005 201.024261,242.609680
|
||||
C190.759888,240.937958 181.220306,237.500244 172.425995,230.682968
|
||||
C168.445160,226.806229 164.947098,223.316956 161.449020,219.827682
|
||||
C159.783066,217.041473 158.117111,214.255264 156.194427,210.941986
|
||||
C155.606812,209.943466 155.275955,209.472000 154.945084,209.000549
|
||||
C154.824081,208.436035 154.703064,207.871521 154.328339,206.755432
|
||||
C153.017212,202.802505 151.959824,199.401123 150.963470,195.682892
|
||||
C150.854065,194.533371 150.683609,193.700714 150.552582,192.411957
|
||||
C150.528458,187.630493 150.464920,183.305115 150.565887,178.718369
|
||||
C150.720535,178.050446 150.710709,177.643890 150.831299,176.893539
|
||||
C155.827408,152.691254 169.772842,136.736740 193.392685,130.815735
|
||||
C201.799973,128.708206 211.104767,130.181046 219.999878,130.019516
|
||||
C220.787079,130.324265 221.574265,130.628998 222.980423,131.084305
|
||||
C224.712799,131.544586 225.826202,131.854324 226.939621,132.164047
|
||||
M169.871216,177.552811
|
||||
C169.871216,179.345963 169.871216,181.139114 169.871216,183.033920
|
||||
C172.403381,183.337494 174.472565,183.585587 176.835190,183.868851
|
||||
C176.691788,185.731430 176.573364,187.269272 176.462082,188.714569
|
||||
C173.976456,188.920944 172.058456,189.080200 170.116074,189.241470
|
||||
C170.116074,192.183273 170.116074,194.619492 170.116074,197.478119
|
||||
C172.777069,197.620667 175.215317,197.751282 177.870010,197.893494
|
||||
C181.333679,210.122269 187.926346,219.590103 200.698456,223.426178
|
||||
C212.752716,227.046646 227.266907,223.561264 234.044220,215.191437
|
||||
C232.148819,212.263336 230.681931,208.550308 227.960358,206.514877
|
||||
C226.892654,205.716339 223.016739,208.678787 220.415283,209.919083
|
||||
C219.965195,210.133667 219.497940,210.315460 219.029770,210.488449
|
||||
C207.612061,214.707397 194.707367,208.462616 193.160904,197.717148
|
||||
C198.682358,197.717148 204.168716,197.720016 209.655075,197.716431
|
||||
C216.784546,197.711777 217.571793,196.804138 216.904953,188.748032
|
||||
C208.240631,188.748032 199.514038,188.748032 190.476959,188.748032
|
||||
C190.644501,186.719986 190.762466,185.292206 190.913162,183.468018
|
||||
C198.324646,183.468018 205.449554,183.470215 212.574463,183.467407
|
||||
C220.263565,183.464386 220.263565,183.462357 220.744034,174.861435
|
||||
C216.097565,174.861435 211.473129,174.861435 206.848694,174.861435
|
||||
C202.260406,174.861435 197.672119,174.861435 192.963257,174.861435
|
||||
C196.611069,160.723328 208.540558,157.492020 227.791489,165.238937
|
||||
C229.103424,163.092438 230.280640,160.727509 231.862915,158.674835
|
||||
C234.597961,155.126678 233.082062,153.304993 229.758621,151.412216
|
||||
C212.998184,141.866791 191.531326,147.659714 182.149368,164.529266
|
||||
C180.387848,167.696640 179.217010,171.192551 178.073868,173.841141
|
||||
C175.035629,174.883667 172.461670,175.766907 169.871216,177.552811
|
||||
z"/>
|
||||
<path fill="#080808" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M212.906448,114.999527
|
||||
C212.820831,116.074493 212.735214,117.149460 212.649750,118.937691
|
||||
C212.735458,120.100494 212.820999,120.550041 212.906540,120.999580
|
||||
C212.715576,121.803833 212.524628,122.608078 211.702850,123.607803
|
||||
C192.463501,122.602257 176.472214,128.564758 162.952820,141.342560
|
||||
C161.897156,142.340332 160.765289,143.786575 159.515930,143.967346
|
||||
C148.610214,145.545074 141.534775,140.083862 141.278656,130.261414
|
||||
C141.187683,126.772896 141.238998,123.280090 141.257996,119.789413
|
||||
C141.312943,109.696671 146.180252,104.689011 156.170547,104.646538
|
||||
C171.130920,104.582932 186.092590,104.526749 201.052063,104.657257
|
||||
C211.095612,104.744881 212.398514,102.577766 212.906448,114.999527
|
||||
M166.341217,119.359665
|
||||
C162.026627,113.777344 153.895370,116.770874 152.839142,122.567940
|
||||
C152.173843,126.219276 153.150681,129.743210 156.577988,130.882767
|
||||
C159.144333,131.736023 162.951035,131.519882 165.178162,130.131714
|
||||
C168.861877,127.835670 168.892929,123.798790 166.341217,119.359665
|
||||
z"/>
|
||||
<path fill="#FCFCFC" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M213.293289,114.991913
|
||||
C212.398514,102.577766 211.095612,104.744881 201.052063,104.657257
|
||||
C186.092590,104.526749 171.130920,104.582932 156.170547,104.646538
|
||||
C146.180252,104.689011 141.312943,109.696671 141.257996,119.789413
|
||||
C141.238998,123.280090 141.187683,126.772896 141.278656,130.261414
|
||||
C141.534775,140.083862 148.610214,145.545074 159.515930,143.967346
|
||||
C160.765289,143.786575 161.897156,142.340332 162.952820,141.342560
|
||||
C176.472214,128.564758 192.463501,122.602257 211.535736,123.871864
|
||||
C213.734222,124.556549 215.643204,125.969444 217.170319,125.635582
|
||||
C221.335602,124.724968 220.920670,126.668648 220.033813,129.665939
|
||||
C211.104767,130.181046 201.799973,128.708206 193.392685,130.815735
|
||||
C169.772842,136.736740 155.827408,152.691254 150.432465,176.822556
|
||||
C148.354355,176.730072 146.805466,176.364761 145.256592,175.999451
|
||||
C148.092514,167.280121 150.928452,158.560776 153.844849,149.594009
|
||||
C153.391983,149.515579 151.649017,149.314178 149.956894,148.903381
|
||||
C142.390503,147.066452 136.568054,139.987091 136.204544,131.893768
|
||||
C135.981155,126.920120 135.975510,121.922020 136.175476,116.947098
|
||||
C136.585281,106.751511 144.516846,99.366196 155.264938,99.278084
|
||||
C170.878281,99.150085 186.493301,99.225655 202.568558,99.112335
|
||||
C204.986130,99.668182 206.942734,100.326111 208.899353,100.984032
|
||||
C209.070557,100.684860 209.241760,100.385681 209.412979,100.086502
|
||||
C211.816177,103.166245 214.608368,106.029137 216.487976,109.400780
|
||||
C217.774597,111.708717 217.065338,114.348396 213.293289,114.991913
|
||||
z"/>
|
||||
<path fill="#FCFCFC" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M150.902435,195.999741
|
||||
C151.959824,199.401123 153.017212,202.802505 154.089203,206.583511
|
||||
C151.670242,207.108597 150.037033,206.782486 148.698380,203.966873
|
||||
C145.797623,197.865662 139.561264,197.183014 133.783691,197.091965
|
||||
C117.256798,196.831512 100.721970,196.881439 84.192543,197.043152
|
||||
C73.769493,197.145126 63.352028,197.842606 52.929169,197.930542
|
||||
C47.162224,197.979187 41.380878,197.414978 35.627686,196.889771
|
||||
C34.702950,196.805328 33.891865,195.476028 33.014526,194.302078
|
||||
C42.981415,193.781113 52.961884,193.613190 62.942661,193.592377
|
||||
C89.925507,193.536133 116.908478,193.542435 144.399017,193.906738
|
||||
C145.590561,199.468750 149.017258,195.032043 150.902435,195.999741
|
||||
z"/>
|
||||
<path fill="#FCFCFC" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M161.134308,220.005066
|
||||
C164.947098,223.316956 168.445160,226.806229 172.101227,230.598114
|
||||
C165.250214,230.120361 160.731720,225.886795 161.134308,220.005066
|
||||
z"/>
|
||||
<path fill="#FCFCFC" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M257.962524,160.124161
|
||||
C255.545227,157.028000 253.268845,153.558838 250.881760,149.780853
|
||||
C251.390015,149.048187 252.127518,148.156204 252.608292,148.277969
|
||||
C255.341415,148.970123 258.965668,156.741821 257.962524,160.124161
|
||||
z"/>
|
||||
<path fill="#FCFCFC" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M184.023804,63.261856
|
||||
C183.556564,63.699310 183.108688,63.763706 182.331055,63.373825
|
||||
C182.667435,60.963272 183.333557,59.007004 183.999680,57.050735
|
||||
C185.167892,57.057884 186.336105,57.065029 187.827362,57.215778
|
||||
C186.781326,59.202522 185.412231,61.045658 184.023804,63.261856
|
||||
z"/>
|
||||
<path fill="#FCFCFC" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M14.859860,65.992538
|
||||
C15.312203,67.393913 15.414304,68.806534 15.364571,70.621033
|
||||
C14.153467,70.378609 13.094196,69.734314 12.034927,69.090012
|
||||
C12.859825,68.061272 13.684721,67.032524 14.859860,65.992538
|
||||
z"/>
|
||||
<path fill="#FCFCFC" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M203.543533,76.987602
|
||||
C202.574036,75.226929 201.975403,73.453110 201.313843,71.337997
|
||||
C202.765976,71.923782 204.374023,72.745483 205.685516,73.903152
|
||||
C205.861618,74.058594 204.541611,75.908958 203.543533,76.987602
|
||||
z"/>
|
||||
<path fill="#FCFCFC" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M26.999981,193.431641
|
||||
C28.136013,193.180038 29.272118,193.332687 30.703493,193.665924
|
||||
C30.184053,195.170471 29.369345,196.494431 28.554638,197.818375
|
||||
C28.036444,196.490875 27.518251,195.163376 26.999981,193.431641
|
||||
z"/>
|
||||
<path fill="#FCFCFC" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M14.860611,112.992096
|
||||
C15.299127,114.070244 15.400163,115.159042 15.351123,116.635345
|
||||
C14.158072,116.382713 13.115099,115.742569 12.072126,115.102432
|
||||
C12.889128,114.402542 13.706128,113.702652 14.860611,112.992096
|
||||
z"/>
|
||||
<path fill="#FCFCFC" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M226.955307,131.772522
|
||||
C225.826202,131.854324 224.712799,131.544586 223.300369,131.052551
|
||||
C223.895004,129.972595 224.788651,129.074966 225.682297,128.177338
|
||||
C226.111862,129.245224 226.541428,130.313095 226.955307,131.772522
|
||||
z"/>
|
||||
<path fill="#FCFCFC" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M251.902466,220.854553
|
||||
C252.758133,218.880341 254.016525,216.925964 255.565536,214.777496
|
||||
C256.204529,217.545151 255.958267,220.171265 251.902466,220.854553
|
||||
z"/>
|
||||
<path fill="#FCFCFC" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M191.011780,47.996571
|
||||
C190.231232,47.649754 189.596527,46.967026 188.822052,46.037025
|
||||
C189.711639,45.118870 190.741013,44.447994 191.770401,43.777119
|
||||
C192.086258,44.076664 192.402130,44.376209 192.718002,44.675755
|
||||
C192.197891,45.670723 191.677765,46.665691 191.011780,47.996571
|
||||
z"/>
|
||||
<path fill="#FCFCFC" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M201.021790,243.006012
|
||||
C201.781616,242.615005 202.538971,242.620331 203.648132,242.780487
|
||||
C203.578094,243.872162 203.156250,244.809021 202.734421,245.745880
|
||||
C202.162720,244.964706 201.591019,244.183517 201.021790,243.006012
|
||||
z"/>
|
||||
<path fill="#FCFCFC" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M34.003681,43.211102
|
||||
C33.555176,43.611732 33.103672,43.681213 32.321953,43.645020
|
||||
C32.169670,42.507217 32.347603,41.475090 32.525536,40.442963
|
||||
C32.795609,40.427322 33.065678,40.411682 33.335751,40.396042
|
||||
C33.557392,41.224010 33.779034,42.051983 34.003681,43.211102
|
||||
z"/>
|
||||
<path fill="#FCFCFC" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M14.935968,58.991409
|
||||
C15.334212,59.445126 15.426287,59.905792 15.368013,60.693192
|
||||
C14.283467,60.887386 13.349272,60.754856 12.415075,60.622326
|
||||
C12.374978,60.366383 12.334882,60.110435 12.294785,59.854488
|
||||
C13.073122,59.569115 13.851460,59.283741 14.935968,58.991409
|
||||
z"/>
|
||||
<path fill="#FCFCFC" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M245.164459,228.211670
|
||||
C245.484512,227.379807 245.978790,226.786591 246.754593,226.102509
|
||||
C246.470322,226.824554 245.904510,227.637421 245.164459,228.211670
|
||||
z"/>
|
||||
<path fill="#FCFCFC" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M213.260101,120.994545
|
||||
C212.820999,120.550041 212.735458,120.100494 212.777039,119.325653
|
||||
C213.762344,119.368111 214.620529,119.735878 215.478714,120.103645
|
||||
C214.857025,120.398933 214.235352,120.694221 213.260101,120.994545
|
||||
z"/>
|
||||
<path fill="#FCFCFC" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M210.005981,243.304291
|
||||
C210.443192,242.837326 210.886246,242.723969 211.663177,242.708725
|
||||
C211.651260,243.697159 211.305450,244.587494 210.959625,245.477814
|
||||
C210.643692,244.871170 210.327759,244.264526 210.005981,243.304291
|
||||
z"/>
|
||||
<path fill="#FCFCFC" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M154.638519,209.048279
|
||||
C155.275955,209.472000 155.606812,209.943466 155.935715,210.707565
|
||||
C155.107086,210.688675 154.280426,210.377136 153.453751,210.065582
|
||||
C153.746475,209.742401 154.039215,209.419189 154.638519,209.048279
|
||||
z"/>
|
||||
<path fill="#FCFCFC" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M22.310106,190.873505
|
||||
C23.096842,190.948639 23.590258,191.164581 24.207485,191.622192
|
||||
C23.477751,192.079514 22.624203,192.295135 21.770657,192.510773
|
||||
C21.852701,192.011948 21.934744,191.513123 22.310106,190.873505
|
||||
z"/>
|
||||
<path fill="#FDFDFD" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M183.540741,56.948952
|
||||
C183.333557,59.007004 182.667435,60.963272 182.002045,63.277351
|
||||
C159.975876,63.698380 137.948975,63.761604 115.471336,63.373684
|
||||
C115.216957,61.821060 115.413292,60.719574 115.647171,59.407505
|
||||
C114.127228,59.407505 112.801582,59.407505 111.351570,59.407505
|
||||
C111.606758,61.078743 111.801445,62.353710 111.996124,63.628677
|
||||
C96.638107,63.694241 81.280090,63.759804 65.472351,63.364048
|
||||
C65.211647,61.802021 65.400665,60.701309 65.594566,59.572136
|
||||
C61.432590,59.572136 57.548336,59.572136 53.664082,59.572136
|
||||
C53.666157,60.048668 53.668232,60.525204 53.670307,61.001736
|
||||
C55.375309,61.001736 57.172268,60.670033 58.755596,61.108952
|
||||
C59.974983,61.446983 60.925861,62.753632 61.996136,63.629578
|
||||
C51.443180,63.629578 40.890221,63.629578 30.099171,63.629578
|
||||
C30.506683,60.962025 30.773939,59.212589 31.135298,56.847168
|
||||
C81.709724,56.847168 132.395752,56.847168 183.540741,56.948952
|
||||
M76.203041,59.000599
|
||||
C73.997635,59.143551 71.792229,59.286503 69.586823,59.429455
|
||||
C69.633072,59.724621 69.679321,60.019791 69.725571,60.314957
|
||||
C72.934212,60.314957 76.142853,60.314957 79.351494,60.314957
|
||||
C79.348328,60.011353 79.345154,59.707748 79.341988,59.404144
|
||||
C78.574745,59.270885 77.807503,59.137623 76.203041,59.000599
|
||||
M88.435844,60.358826
|
||||
C87.555870,59.973251 86.675888,59.587681 85.795906,59.202110
|
||||
C85.700119,59.620235 85.604332,60.038361 85.508545,60.456482
|
||||
C86.324127,60.565514 87.139717,60.674545 88.435844,60.358826
|
||||
M39.926178,61.194210
|
||||
C40.213997,60.810238 40.501816,60.426266 40.789631,60.042286
|
||||
C40.416054,59.865704 40.042477,59.689117 39.668892,59.512535
|
||||
C39.621964,59.944340 39.575031,60.376144 39.926178,61.194210
|
||||
M101.604912,59.799271
|
||||
C101.213028,59.795044 100.821152,59.790813 100.429268,59.786587
|
||||
C100.427719,59.949203 100.415245,60.253147 100.426201,60.253994
|
||||
C100.816139,60.284134 101.208275,60.285900 101.604912,59.799271
|
||||
z"/>
|
||||
<path fill="#FCFCFC" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M150.401382,178.979752
|
||||
C150.464920,183.305115 150.528458,187.630493 150.093063,192.445770
|
||||
C148.061493,192.957123 146.528824,192.978561 144.996185,193.000000
|
||||
C144.899200,188.628265 144.802216,184.256516 145.310516,179.442383
|
||||
C147.411011,178.993240 148.906189,178.986496 150.401382,178.979752
|
||||
z"/>
|
||||
<path fill="#FDFDFD" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M62.365829,63.727013
|
||||
C60.925861,62.753632 59.974983,61.446983 58.755596,61.108952
|
||||
C57.172268,60.670033 55.375309,61.001736 53.670307,61.001736
|
||||
C53.668232,60.525204 53.666157,60.048668 53.664082,59.572136
|
||||
C57.548336,59.572136 61.432590,59.572136 65.594566,59.572136
|
||||
C65.400665,60.701309 65.211647,61.802021 65.013237,63.266251
|
||||
C64.247734,63.694668 63.491627,63.759560 62.365829,63.727013
|
||||
z"/>
|
||||
<path fill="#FDFDFD" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M144.860291,193.086334
|
||||
C146.528824,192.978561 148.061493,192.957123 150.053650,192.901855
|
||||
C150.683609,193.700714 150.854065,194.533371 150.963470,195.682892
|
||||
C149.017258,195.032043 145.590561,199.468750 144.853806,193.887466
|
||||
C144.800980,193.485397 144.724396,193.172668 144.860291,193.086334
|
||||
z"/>
|
||||
<path fill="#FDFDFD" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M112.365799,63.726276
|
||||
C111.801445,62.353710 111.606758,61.078743 111.351570,59.407505
|
||||
C112.801582,59.407505 114.127228,59.407505 115.647171,59.407505
|
||||
C115.413292,60.719574 115.216957,61.821060 115.012238,63.275852
|
||||
C114.247726,63.694069 113.491600,63.758972 112.365799,63.726276
|
||||
z"/>
|
||||
<path fill="#FDFDFD" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M150.565887,178.718369
|
||||
C148.906189,178.986496 147.411011,178.993240 145.468597,178.999954
|
||||
C145.015717,178.240692 145.010056,177.481461 145.130493,176.360840
|
||||
C146.805466,176.364761 148.354355,176.730072 150.302063,177.166367
|
||||
C150.710709,177.643890 150.720535,178.050446 150.565887,178.718369
|
||||
z"/>
|
||||
<path fill="#F7F7F7" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M169.879456,177.101471
|
||||
C172.461670,175.766907 175.035629,174.883667 178.073868,173.841141
|
||||
C179.217010,171.192551 180.387848,167.696640 182.149368,164.529266
|
||||
C191.531326,147.659714 212.998184,141.866791 229.758621,151.412216
|
||||
C233.082062,153.304993 234.597961,155.126678 231.862915,158.674835
|
||||
C230.280640,160.727509 229.103424,163.092438 227.791489,165.238937
|
||||
C208.540558,157.492020 196.611069,160.723328 192.963257,174.861435
|
||||
C197.672119,174.861435 202.260406,174.861435 206.848694,174.861435
|
||||
C211.473129,174.861435 216.097565,174.861435 220.744034,174.861435
|
||||
C220.263565,183.462357 220.263565,183.464386 212.574463,183.467407
|
||||
C205.449554,183.470215 198.324646,183.468018 190.913162,183.468018
|
||||
C190.762466,185.292206 190.644501,186.719986 190.476959,188.748032
|
||||
C199.514038,188.748032 208.240631,188.748032 216.904953,188.748032
|
||||
C217.571793,196.804138 216.784546,197.711777 209.655075,197.716431
|
||||
C204.168716,197.720016 198.682358,197.717148 193.160904,197.717148
|
||||
C194.707367,208.462616 207.612061,214.707397 219.029770,210.488449
|
||||
C219.497940,210.315460 219.965195,210.133667 220.415283,209.919083
|
||||
C223.016739,208.678787 226.892654,205.716339 227.960358,206.514877
|
||||
C230.681931,208.550308 232.148819,212.263336 234.044220,215.191437
|
||||
C227.266907,223.561264 212.752716,227.046646 200.698456,223.426178
|
||||
C187.926346,219.590103 181.333679,210.122269 177.870010,197.893494
|
||||
C175.215317,197.751282 172.777069,197.620667 170.116074,197.478119
|
||||
C170.116074,194.619492 170.116074,192.183273 170.116074,189.241470
|
||||
C172.058456,189.080200 173.976456,188.920944 176.462082,188.714569
|
||||
C176.573364,187.269272 176.691788,185.731430 176.835190,183.868851
|
||||
C174.472565,183.585587 172.403381,183.337494 169.871216,183.033920
|
||||
C169.871216,181.139114 169.871216,179.345963 169.879456,177.101471
|
||||
z"/>
|
||||
<path fill="#F5F5F5" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M166.582550,119.661797
|
||||
C168.892929,123.798790 168.861877,127.835670 165.178162,130.131714
|
||||
C162.951035,131.519882 159.144333,131.736023 156.577988,130.882767
|
||||
C153.150681,129.743210 152.173843,126.219276 152.839142,122.567940
|
||||
C153.895370,116.770874 162.026627,113.777344 166.582550,119.661797
|
||||
z"/>
|
||||
<path fill="#FDFDFD" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M76.621651,59.002480
|
||||
C77.807503,59.137623 78.574745,59.270885 79.341988,59.404144
|
||||
C79.345154,59.707748 79.348328,60.011353 79.351494,60.314957
|
||||
C76.142853,60.314957 72.934212,60.314957 69.725571,60.314957
|
||||
C69.679321,60.019791 69.633072,59.724621 69.586823,59.429455
|
||||
C71.792229,59.286503 73.997635,59.143551 76.621651,59.002480
|
||||
z"/>
|
||||
<path fill="#FDFDFD" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M88.195572,60.571201
|
||||
C87.139717,60.674545 86.324127,60.565514 85.508545,60.456482
|
||||
C85.604332,60.038361 85.700119,59.620235 85.795906,59.202110
|
||||
C86.675888,59.587681 87.555870,59.973251 88.195572,60.571201
|
||||
z"/>
|
||||
<path fill="#FDFDFD" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M39.727139,61.001080
|
||||
C39.575031,60.376144 39.621964,59.944340 39.668892,59.512535
|
||||
C40.042477,59.689117 40.416054,59.865704 40.789635,60.042290
|
||||
C40.501816,60.426266 40.213997,60.810238 39.727139,61.001080
|
||||
z"/>
|
||||
<path fill="#FDFDFD" opacity="1.000000" stroke="none"
|
||||
d="
|
||||
M101.602509,60.045452
|
||||
C101.208275,60.285900 100.816139,60.284134 100.426201,60.253994
|
||||
C100.415245,60.253147 100.427719,59.949203 100.429268,59.786583
|
||||
C100.821152,59.790813 101.213028,59.795044 101.602509,60.045452
|
||||
z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 22 KiB |
@@ -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 `<image>`).
|
||||
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.
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 806 B |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 680 B |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1,16 @@
|
||||
<svg width="100" height="19" viewBox="0 0 100 19" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg" class="!h-6 !w-auto">
|
||||
<g id="Logo AX">
|
||||
<path id="Vector" d="M7.46577 5.96218L6.84991 4.29272L2.92383 14.23H4.38649L4.69442 13.435L5.15631 12.163L6.84991 7.63164L7.46577 5.96218Z" fill="currentColor"></path>
|
||||
<path id="Vector_2" d="M9.77632 5.96218L9.16046 4.29272L5.23438 14.23H6.69703L7.00496 13.435L7.46685 12.163L9.16046 7.63164L9.77632 5.96218Z" fill="currentColor"></path>
|
||||
<path id="Vector_3" d="M13.6273 13.435L13.9352 14.23H15.3979L11.3948 4.29272L7.46875 14.23H8.93141L9.23934 13.435L9.70123 12.163L11.3948 7.63164L13.0884 12.163L13.6273 13.435Z" fill="currentColor"></path>
|
||||
<path id="Vector_4" d="M0.461892 0.476987H17.9368V18.523H0.461892V0.476987ZM0 19H18.3987V0H0V19Z" fill="currentColor"></path>
|
||||
<path id="Vector_5" d="M23.0957 14.2702H24.5584L24.8663 13.4753L25.3282 12.2033L27.0218 7.67192L28.4844 11.5673H26.4829L26.021 12.8393H29.0233L29.2543 13.4753L29.5622 14.2702H31.0249L27.0218 4.33301L23.0957 14.2702Z" fill="currentColor"></path>
|
||||
<path id="Vector_6" d="M40.8 11.8849L34.5645 4.17358V14.3493H35.8731V7.98948L40.8 14.2698H42.1087V4.33258H40.8V11.8849Z" fill="currentColor"></path>
|
||||
<path id="Vector_7" d="M45.8047 14.2702H47.2673L47.5753 13.4753L48.0372 12.2033L49.7308 7.67192L51.1934 11.5673H49.1919L48.73 12.8393H51.7323L51.9633 13.4753L52.2712 14.2702H53.7338L49.7308 4.33301L45.8047 14.2702Z" fill="currentColor"></path>
|
||||
<path id="Vector_8" d="M60.0441 7.75142L58.4275 4.33301H56.9648L59.3513 9.26188L56.9648 14.2702H58.4275L60.1211 10.8518L61.8147 14.2702H63.2774L60.8909 9.26188L63.2774 4.33301H61.8147L60.0441 7.75142Z" fill="currentColor"></path>
|
||||
<path id="Vector_9" d="M66.5898 14.2702H68.0525L68.3604 13.4753L68.8223 12.2033L70.5159 7.67192L71.9786 11.5673H69.9001L69.5152 12.8393H72.5175L72.7484 13.4753L73.0563 14.2702H74.519L70.5159 4.33301L66.5898 14.2702Z" fill="currentColor"></path>
|
||||
<path id="Vector_10" d="M86.7598 8.78449H81.9099V10.0565H85.2972C85.2202 10.454 85.0662 10.8514 84.9122 11.1694C84.7583 11.4874 84.4503 11.8054 84.2194 12.1234C83.9115 12.3619 83.6035 12.6004 83.2186 12.7594C82.8337 12.9184 82.4488 12.9979 81.9869 12.9979C81.602 12.9979 81.1401 12.9184 80.7552 12.6799C80.3703 12.5209 79.9854 12.2029 79.6775 11.8849C79.3695 11.5669 79.1386 11.1694 78.9846 10.7719C78.5997 9.89747 78.5997 8.86399 78.9846 7.98952C79.1386 7.59203 79.3695 7.19454 79.6775 6.87654C79.9854 6.55855 80.3703 6.32006 80.7552 6.08157C81.1401 5.92257 81.602 5.76357 82.0639 5.76357C82.6028 5.76357 83.2186 5.92257 83.6805 6.24056C84.1424 6.55855 84.5273 6.95604 84.8353 7.43303H86.4519C86.2979 6.95604 85.99 6.55855 85.759 6.24056C85.4511 5.84307 85.1432 5.52508 84.7583 5.28659C84.3734 5.04809 83.9885 4.8096 83.5266 4.6506C83.0647 4.49161 82.6028 4.41211 82.0639 4.41211C81.448 4.41211 80.7552 4.57111 80.2163 4.8096C79.6775 5.04809 79.1386 5.44558 78.6767 5.84307C78.2148 6.32006 77.9069 6.79705 77.6759 7.43303C77.445 8.06901 77.291 8.705 77.291 9.34098C77.291 9.97696 77.445 10.6924 77.6759 11.2489C77.9069 11.8054 78.2918 12.3619 78.6767 12.8389C79.1386 13.3159 79.6005 13.6339 80.2163 13.8724C81.3711 14.3493 82.6028 14.4288 83.7575 13.9519C84.2964 13.7134 84.7583 13.4749 85.2202 13.0774C85.6051 12.6799 85.99 12.2029 86.2209 11.7259C86.5289 11.1694 86.6828 10.6129 86.7598 10.0565C86.7598 9.97696 86.7598 9.81797 86.8368 9.73847C86.9138 9.65897 86.8368 9.49998 86.8368 9.34098C86.8368 9.26148 86.8368 9.18198 86.8368 9.10249C86.7598 8.94349 86.7598 8.86399 86.7598 8.78449Z" fill="currentColor"></path>
|
||||
<path id="Vector_11" d="M98.2303 10.6925C98.0763 11.09 97.8454 11.4875 97.5374 11.8055C97.2295 12.1235 96.8446 12.362 96.4597 12.6005C96.0748 12.7595 95.6129 12.9184 95.151 12.9184C94.6891 12.9184 94.3042 12.8389 93.8423 12.6005C93.4574 12.4415 93.0725 12.1235 92.7645 11.8055C92.1487 11.09 91.7638 10.2155 91.7638 9.26154C91.7638 8.78455 91.8408 8.30757 91.9947 7.83058C92.1487 7.43309 92.3796 7.0356 92.6876 6.63811C92.9955 6.32012 93.3804 6.08163 93.7653 5.84313C94.1502 5.68414 94.6121 5.52514 95.074 5.52514C95.5359 5.52514 95.9978 5.60464 96.3827 5.84313C96.7676 6.00213 97.1525 6.32012 97.4604 6.63811C98.0763 7.35359 98.4612 8.22807 98.4612 9.18204C98.5382 9.81803 98.4612 10.295 98.2303 10.6925ZM99.6159 7.35359C99.1541 6.16112 98.2303 5.20715 97.0755 4.65066C96.4597 4.41217 95.8438 4.25317 95.228 4.25317C94.6121 4.25317 93.9193 4.41217 93.3804 4.65066C92.5336 5.04815 91.7638 5.68414 91.2249 6.47912C90.686 7.27409 90.4551 8.22807 90.4551 9.26154C90.4551 9.89753 90.609 10.613 90.84 11.1695C91.0709 11.726 91.4558 12.2825 91.8408 12.7595C92.3026 13.2364 92.7645 13.5544 93.3804 13.7929C93.9963 14.0314 94.6121 14.1904 95.228 14.1904C95.8438 14.1904 96.5367 14.0314 97.0755 13.7929C98.2303 13.3159 99.1541 12.362 99.6159 11.1695C99.8469 10.5335 100.001 9.89753 100.001 9.26154C100.001 8.62556 99.8469 7.98958 99.6159 7.35359Z" fill="currentColor"></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,59 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Calque_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 143.65 43.16">
|
||||
<path
|
||||
d="m5.38,39.51v-.02c0-1.92,1.39-3.67,3.51-3.67,1.16,0,1.89.34,2.6.93l-.34.39c-.56-.49-1.21-.85-2.28-.85-1.72,0-2.93,1.46-2.93,3.18v.02c0,1.83,1.14,3.2,3.04,3.2.9,0,1.74-.38,2.26-.81v-2.04h-2.37v-.49h2.88v2.74c-.64.57-1.63,1.06-2.79,1.06-2.24,0-3.57-1.65-3.57-3.65"
|
||||
style="fill:#2e3638;" />
|
||||
<path
|
||||
d="m18.55,39.62c1.19,0,2.07-.6,2.07-1.62v-.02c0-.95-.75-1.55-2.04-1.55h-2.42v3.2h2.39Zm-2.92-3.68h2.98c.87,0,1.57.27,2,.7.33.33.54.81.54,1.32v.02c0,1.19-.88,1.87-2.08,2.03l2.34,3.03h-.67l-2.26-2.94h-2.32v2.94h-.53v-7.1Z"
|
||||
style="fill:#2e3638;" />
|
||||
<path
|
||||
d="m31.22,39.51v-.02c0-1.76-1.28-3.2-3.03-3.2s-3.01,1.42-3.01,3.18v.02c0,1.75,1.28,3.19,3.03,3.19s3.01-1.42,3.01-3.18m-6.59,0v-.02c0-1.94,1.43-3.67,3.58-3.67s3.56,1.71,3.56,3.65v.02c0,1.94-1.43,3.67-3.58,3.67s-3.56-1.71-3.56-3.65"
|
||||
style="fill:#2e3638;" />
|
||||
<path
|
||||
d="m35.44,40.08v-4.15h.53v4.1c0,1.67.9,2.64,2.4,2.64s2.36-.87,2.36-2.59v-4.15h.53v4.09c0,2.04-1.19,3.12-2.91,3.12s-2.91-1.08-2.91-3.06"
|
||||
style="fill:#2e3638;" />
|
||||
<path
|
||||
d="m47.72,39.9c1.32,0,2.21-.69,2.21-1.74v-.02c0-1.13-.87-1.71-2.15-1.71h-2.01v3.48h1.95Zm-2.48-3.97h2.58c1.56,0,2.64.8,2.64,2.17v.02c0,1.49-1.29,2.26-2.77,2.26h-1.92v2.65h-.53v-7.1Z"
|
||||
style="fill:#2e3638;" />
|
||||
<polygon
|
||||
points="54 35.94 54 43.04 59.1 43.04 59.1 42.55 54.53 42.55 54.53 39.7 58.59 39.7 58.59 39.21 54.53 39.21 54.53 36.42 59.05 36.42 59.05 35.94 54 35.94"
|
||||
style="fill:#2e3638;" />
|
||||
<polygon
|
||||
points="74 35.94 71.15 40.13 68.3 35.94 67.8 35.94 67.8 43.04 68.31 43.04 68.31 36.87 71.12 40.96 71.16 40.96 73.97 36.87 73.97 43.04 74.49 43.04 74.49 35.94 74 35.94"
|
||||
style="fill:#2e3638;" />
|
||||
<path
|
||||
d="m81.53,36.46l1.87,4.11h-3.74l1.88-4.11Zm-.24-.58l-3.31,7.15h.55l.9-1.99h4.19l.9,1.99h.58l-3.31-7.15h-.51Z"
|
||||
style="fill:#2e3638;" />
|
||||
<path
|
||||
d="m87.86,39.51v-.02c0-1.92,1.39-3.67,3.51-3.67,1.16,0,1.89.34,2.6.93l-.34.39c-.56-.49-1.21-.85-2.28-.85-1.72,0-2.93,1.46-2.93,3.18v.02c0,1.83,1.14,3.2,3.04,3.2.9,0,1.74-.38,2.26-.81v-2.04h-2.37v-.49h2.88v2.74c-.64.57-1.63,1.06-2.79,1.06-2.24,0-3.57-1.65-3.57-3.65"
|
||||
style="fill:#2e3638;" />
|
||||
<polygon
|
||||
points="98.11 35.94 98.11 43.04 103.21 43.04 103.21 42.55 98.64 42.55 98.64 39.7 102.71 39.7 102.71 39.21 98.64 39.21 98.64 36.42 103.16 36.42 103.16 35.94 98.11 35.94"
|
||||
style="fill:#2e3638;" />
|
||||
<polygon
|
||||
points="106.89 35.94 106.89 43.04 111.59 43.04 111.59 42.55 107.42 42.55 107.42 35.94 106.89 35.94"
|
||||
style="fill:#2e3638;" />
|
||||
<polygon
|
||||
points="115.15 35.94 115.15 43.04 119.85 43.04 119.85 42.55 115.68 42.55 115.68 35.94 115.15 35.94"
|
||||
style="fill:#2e3638;" />
|
||||
<rect x="123.48" y="35.93" width=".53" height="7.1" style="fill:#2e3638;" />
|
||||
<polygon
|
||||
points="134.37 35.94 131.52 40.13 128.67 35.94 128.17 35.94 128.17 43.04 128.68 43.04 128.68 36.87 131.49 40.96 131.53 40.96 134.34 36.87 134.34 43.04 134.87 43.04 134.87 35.94 134.37 35.94"
|
||||
style="fill:#2e3638;" />
|
||||
<path
|
||||
d="m19.11,20.11c0,1.17-.43,2.04-1.3,2.59-.87.56-2.15.84-3.85.84h-7.54v-6.9h7.54c3.43,0,5.15,1.16,5.15,3.47m-1.4-11.49c0,1.09-.41,1.91-1.24,2.47-.83.56-2.04.84-3.63.84h-6.42v-6.58h6.42c1.6,0,2.81.27,3.63.82.82.55,1.24,1.36,1.24,2.45m2.79,5.27c1.17-.61,2.08-1.45,2.73-2.51.65-1.06.98-2.27.98-3.63,0-2.23-.91-4-2.73-5.31-1.82-1.3-4.44-1.95-7.84-1.95H0v27.93h14.44c2.82,0,5.11-.41,6.89-1.2l3.89-8.95c-.23-.67-.54-1.29-.96-1.84-.9-1.18-2.15-2.03-3.75-2.53"
|
||||
style="fill:#2e3638;" />
|
||||
<path
|
||||
d="m46.05,17.51l-4.43-10.69-4.43,10.69h8.86Zm2.03,4.91h-12.97l-2.47,5.99h-6.62L38.47.48h6.38l12.49,27.93h-6.78l-2.47-5.99Z"
|
||||
style="fill:#2e3638;" />
|
||||
<polygon points="61.96 6.59 61.96 28.41 78.91 28.41 81.39 22.7 68.24 22.7 68.24 .32 61.96 6.59"
|
||||
style="fill:#2e3638;" />
|
||||
<polygon
|
||||
points="104.57 .5 104.57 .48 80.23 .48 80.23 5.74 89.17 5.74 89.17 28.41 95.63 28.41 95.63 5.74 99.32 5.74 104.57 .5"
|
||||
style="fill:#2e3638;" />
|
||||
<polygon points="107.51 28.34 113.97 28.34 113.97 0 107.51 6.46 107.51 28.34"
|
||||
style="fill:#2e3638;" />
|
||||
<path
|
||||
d="m125.39,27.99c-2.06-.6-3.72-1.38-4.97-2.33l2.19-4.87c1.2.88,2.62,1.58,4.27,2.11,1.65.53,3.3.8,4.95.8,1.84,0,3.19-.27,4.07-.82.88-.54,1.32-1.27,1.32-2.17,0-.66-.26-1.22-.78-1.66-.52-.44-1.18-.79-2-1.06-.81-.27-1.91-.56-3.29-.88-2.13-.5-3.87-1.01-5.23-1.52-1.36-.51-2.52-1.32-3.49-2.43-.97-1.12-1.46-2.61-1.46-4.47,0-1.62.44-3.09,1.32-4.41.88-1.32,2.2-2.36,3.97-3.13,1.77-.77,3.93-1.16,6.48-1.16,1.78,0,3.52.21,5.23.64,1.7.43,3.19,1.04,4.47,1.84l-2,4.91c-2.58-1.46-5.16-2.19-7.74-2.19-1.81,0-3.15.29-4.01.88-.87.59-1.3,1.36-1.3,2.31s.5,1.67,1.5,2.13c1,.47,2.52.93,4.57,1.38,2.13.51,3.87,1.01,5.23,1.52,1.36.51,2.52,1.3,3.49,2.39.97,1.09,1.46,2.57,1.46,4.43,0,1.6-.45,3.05-1.34,4.37-.89,1.32-2.23,2.36-4.01,3.13-1.78.77-3.95,1.16-6.5,1.16-2.21,0-4.34-.3-6.4-.9"
|
||||
style="fill:#2e3638;" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
@@ -0,0 +1,39 @@
|
||||
<svg fill="none" height="34" viewBox="0 0 178 34" width="178" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1761_2880)">
|
||||
<path d="M30.6416 17.0182C28.4023 20.3043 24.6142 22.0247 20.8249 21.7674C19.2098 21.6577 17.5946 21.1905 16.0982 20.3315C14.6017 19.4725 13.3865 18.3149 12.4845 16.9792C12.3362 17.1975 12.1938 17.4217 12.0597 17.653C10.1657 20.9144 10.1609 24.7456 11.7072 27.8925C12.6317 29.7733 14.1104 31.4099 16.0709 32.5356C21.3115 35.5444 28.0142 33.7592 31.0392 28.5474C33.2014 24.8235 32.9012 20.3598 30.6416 17.0158V17.0182Z" fill="url(#paint0_linear_1761_2880)"></path>
|
||||
<path d="M12.0588 17.6544C12.1929 17.4231 12.3353 17.1977 12.4836 16.9806C14.4275 14.1287 17.5368 12.4567 20.8015 12.2314C19.877 10.3505 18.3983 8.71397 16.4378 7.5883C14.9414 6.72931 13.3262 6.26205 11.7111 6.15231C7.66904 5.87857 3.63057 7.85261 1.46833 11.5765C-1.55785 16.7871 0.237684 23.4514 5.4795 26.4591C7.44 27.5848 9.6046 28.039 11.7051 27.8939C10.1588 24.747 10.1636 20.9157 12.0576 17.6544H12.0588Z" fill="url(#paint1_linear_1761_2880)"></path>
|
||||
<path d="M27.0552 1.46197C21.8146 -1.54688 15.1131 0.238369 12.0869 5.44899C11.9528 5.68026 11.8282 5.91506 11.7119 6.15223C13.3271 6.26197 14.9422 6.72922 16.4387 7.58822C18.3992 8.71389 19.8779 10.3505 20.8023 12.2313C22.2395 15.1552 22.3368 18.669 20.8249 21.7676C24.6153 22.0236 28.4022 20.3044 30.6416 17.0183C30.7899 16.8 30.9323 16.5758 31.0664 16.3446C34.0926 11.1339 32.2971 4.46964 27.0552 1.46197Z" fill="url(#paint2_linear_1761_2880)"></path>
|
||||
<path d="M15.778 8.94624C16.8271 10.0908 17.5854 11.3982 18.0554 12.7763C18.9419 12.4802 19.8651 12.2961 20.8015 12.2324C19.877 10.3515 18.3983 8.71497 16.4378 7.58931C14.9414 6.73031 13.3262 6.26305 11.7111 6.15332C13.217 6.74447 14.6126 7.67662 15.7768 8.94624H15.778Z" fill="#311EC1"></path>
|
||||
<path d="M12.1063 22.9606C12.5786 21.4845 13.3381 20.1783 14.3029 19.0845C13.6015 18.4685 12.9797 17.7665 12.4552 16.9924C11.2791 18.7293 10.592 20.8202 10.592 23.0715C10.592 24.7895 10.9919 26.4143 11.7051 27.8597C11.4678 26.2679 11.5817 24.5995 12.1051 22.9618L12.1063 22.9606Z" fill="#0076C6"></path>
|
||||
<path d="M26.2047 19.0855C24.6833 19.417 23.1654 19.4159 21.7295 19.1315C21.5443 20.0424 21.2429 20.9297 20.8311 21.7675C22.9316 21.9126 25.0963 21.4572 27.0567 20.3327C28.5532 19.4737 29.7684 18.3162 30.6704 16.9805C29.4029 17.9811 27.8922 18.7173 26.2047 19.0843V19.0855Z" fill="#00AFAA"></path>
|
||||
<path d="M12.1063 22.9606C12.5786 21.4845 13.3381 20.1783 14.3029 19.0845C13.6015 18.4685 12.9797 17.7665 12.4552 16.9924C11.2791 18.7293 10.592 20.8202 10.592 23.0715C10.592 24.7895 10.9919 26.4143 11.7051 27.8597C11.4678 26.2679 11.5817 24.5995 12.1051 22.9618L12.1063 22.9606Z" fill="#0079DD"></path>
|
||||
<path d="M43.0225 27.1278V7.83105H52.8463C54.7949 7.83105 56.2463 8.28533 57.2004 9.19389C58.1546 10.1036 58.6316 11.3862 58.6316 13.044C58.6316 14.0824 58.4311 14.9461 58.0288 15.6364C57.6265 16.3255 57.0841 16.86 56.4017 17.2387C57.306 17.5184 58.024 17.9833 58.5569 18.6322C59.0885 19.2812 59.3555 20.2346 59.3555 21.4936C59.3555 23.3508 58.8286 24.755 57.7736 25.7036C56.7186 26.6523 55.1972 27.1266 53.2082 27.1266H43.0225V27.1278ZM47.0906 15.8322H52.0927C53.0765 15.8322 53.7802 15.6175 54.2015 15.188C54.624 14.7585 54.8341 14.1544 54.8341 13.3756C54.8341 12.5166 54.6181 11.8771 54.1861 11.4582C53.7541 11.0381 52.9555 10.8293 51.7901 10.8293H47.0894V15.8334L47.0906 15.8322ZM47.0906 24.1614H52.2434C53.2676 24.1614 54.0211 23.9514 54.5042 23.5325C54.986 23.1137 55.2269 22.3738 55.2269 21.3154C55.2269 20.4564 54.9907 19.8169 54.5184 19.398C54.0461 18.978 53.1976 18.7691 51.9717 18.7691H47.0894V24.1626L47.0906 24.1614Z" fill="#313130"></path>
|
||||
<path d="M61.6602 27.1279V15.922V12.9262H65.7283V27.1279H61.6602ZM61.6602 10.3185V7.14209H65.758V10.3185H61.6602Z" fill="#313130"></path>
|
||||
<path d="M75.6633 27.4264C73.1925 27.4264 71.2332 26.8128 69.7865 25.5833C68.3399 24.355 67.6172 22.4931 67.6172 19.9951C67.6172 17.7379 68.2248 15.9456 69.44 14.6169C70.6552 13.2883 72.4686 12.624 74.88 12.624C77.0897 12.624 78.782 13.1987 79.9581 14.3467C81.1329 15.496 81.7204 16.9981 81.7204 18.8565V21.4335H71.3839C71.6046 22.5721 72.1327 23.3509 72.9658 23.7709C73.7989 24.1898 74.9797 24.3998 76.507 24.3998C77.2701 24.3998 78.0486 24.3302 78.8425 24.1898C79.6365 24.0506 80.3141 23.87 80.8766 23.6506V26.5273C80.2132 26.827 79.4395 27.0512 78.5565 27.201C77.6724 27.3509 76.7076 27.4252 75.6633 27.4252V27.4264ZM71.3839 18.9473H78.1649V18.1686C78.1649 17.3497 77.924 16.7054 77.4422 16.2358C76.9604 15.7662 76.1463 15.5314 75.0011 15.5314C73.6553 15.5314 72.7154 15.8016 72.1838 16.3408C71.6509 16.8801 71.3851 17.7485 71.3851 18.9473H71.3839Z" fill="#313130"></path>
|
||||
<path d="M83.7451 27.1279V12.9262H87.5118L87.6625 14.2748C88.2452 13.8359 88.9834 13.4512 89.877 13.1209C90.7706 12.7917 91.7105 12.6265 92.6943 12.6265C94.5824 12.6265 95.959 13.0666 96.823 13.9445C97.6869 14.8235 98.1189 16.1816 98.1189 18.0188V27.1268H94.0508V18.2276C94.0508 17.2684 93.8549 16.5899 93.4633 16.1899C93.0717 15.7911 92.343 15.5905 91.2785 15.5905C90.6555 15.5905 90.0277 15.7309 89.3952 16.0094C88.7626 16.289 88.2345 16.6383 87.8133 17.0583V27.1256H83.7451V27.1279Z" fill="#313130"></path>
|
||||
<path d="M100.594 33.5987V12.9249H104.12L104.331 14.2429C104.954 13.7237 105.636 13.3249 106.38 13.0441C107.123 12.7644 107.998 12.624 109.002 12.624C111.092 12.624 112.703 13.188 113.839 14.3172C114.973 15.4464 115.542 17.2883 115.542 19.8453C115.542 22.4022 114.934 24.3054 113.719 25.5526C112.504 26.801 110.851 27.4252 108.762 27.4252C107.155 27.4252 105.789 27.0653 104.664 26.3467V33.5975H100.596L100.594 33.5987ZM107.736 24.46C108.941 24.46 109.855 24.0907 110.478 23.3509C111.1 22.6122 111.412 21.4535 111.412 19.8748C111.412 18.296 111.131 17.2636 110.568 16.5945C110.006 15.9255 109.101 15.5904 107.855 15.5904C106.609 15.5904 105.545 16.0199 104.662 16.8789V23.3804C105.083 23.7001 105.531 23.9597 106.003 24.1591C106.475 24.3585 107.053 24.4588 107.736 24.4588V24.46Z" fill="#313130"></path>
|
||||
<path d="M117.499 27.1279V12.9262H121.266L121.446 14.394C122.089 13.9952 122.888 13.6294 123.842 13.3002C124.796 12.971 125.735 12.7456 126.659 12.6265V15.6825C126.117 15.7627 125.524 15.8819 124.882 16.0424C124.239 16.2029 123.626 16.3822 123.043 16.5816C122.461 16.781 121.968 16.9911 121.567 17.2105V27.1279H117.499Z" fill="#313130"></path>
|
||||
<path d="M135.2 27.4264C132.729 27.4264 130.77 26.8128 129.324 25.5833C127.878 24.355 127.155 22.4931 127.155 19.9951C127.155 17.7379 127.763 15.9456 128.978 14.6169C130.193 13.2883 132.006 12.624 134.418 12.624C136.628 12.624 138.32 13.1987 139.496 14.3467C140.671 15.496 141.259 16.9981 141.259 18.8565V21.4335H130.923C131.144 22.5721 131.672 23.3509 132.505 23.7709C133.338 24.1898 134.519 24.3998 136.046 24.3998C136.809 24.3998 137.588 24.3302 138.382 24.1898C139.176 24.0506 139.853 23.87 140.416 23.6506V26.5273C139.753 26.827 138.979 27.0512 138.096 27.201C137.211 27.3509 136.247 27.4252 135.202 27.4252L135.2 27.4264ZM130.921 18.9473H137.702V18.1686C137.702 17.3497 137.461 16.7054 136.979 16.2358C136.496 15.7662 135.683 15.5314 134.538 15.5314C133.192 15.5314 132.252 15.8016 131.72 16.3408C131.188 16.8801 130.922 17.7485 130.922 18.9473H130.921Z" fill="#313130"></path>
|
||||
<path d="M148.938 27.4265C147.29 27.4265 146.069 26.997 145.277 26.138C144.483 25.279 144.086 24.1109 144.086 22.6324V16.0413H142.068V12.925H144.086V9.86899L148.154 8.67017V12.925H151.77L151.529 16.0413H148.154V22.3634C148.154 23.1421 148.335 23.6767 148.697 23.9669C149.059 24.2572 149.621 24.4011 150.384 24.4011C150.947 24.4011 151.529 24.3008 152.132 24.1014V26.8885C151.69 27.0678 151.208 27.2035 150.686 27.2932C150.164 27.3828 149.581 27.4277 148.938 27.4277V27.4265Z" fill="#313130"></path>
|
||||
<path d="M160.757 27.4264C158.286 27.4264 156.327 26.8128 154.881 25.5833C153.435 24.355 152.712 22.4931 152.712 19.9951C152.712 17.7379 153.32 15.9456 154.535 14.6169C155.75 13.2883 157.563 12.624 159.975 12.624C162.184 12.624 163.877 13.1987 165.053 14.3467C166.228 15.496 166.816 16.9981 166.816 18.8565V21.4335H156.48C156.701 22.5721 157.229 23.3509 158.062 23.7709C158.895 24.1898 160.076 24.3998 161.603 24.3998C162.366 24.3998 163.145 24.3302 163.938 24.1898C164.732 24.0506 165.41 23.87 165.973 23.6506V26.5273C165.31 26.827 164.535 27.0512 163.652 27.201C162.768 27.3509 161.804 27.4252 160.759 27.4252L160.757 27.4264ZM156.477 18.9473H163.258V18.1686C163.258 17.3497 163.018 16.7054 162.536 16.2358C162.053 15.7662 161.24 15.5314 160.095 15.5314C158.749 15.5314 157.809 15.8016 157.277 16.3408C156.744 16.8801 156.479 17.7485 156.479 18.9473H156.477Z" fill="#313130"></path>
|
||||
<path d="M168.838 27.1279V12.9262H172.605L172.785 14.394C173.427 13.9952 174.227 13.6294 175.181 13.3002C176.135 12.971 177.075 12.7456 177.998 12.6265V15.6825C177.456 15.7627 176.863 15.8819 176.221 16.0424C175.578 16.2029 174.965 16.3822 174.382 16.5816C173.8 16.781 173.307 16.9911 172.906 17.2105V27.1279H168.838Z" fill="#313130"></path>
|
||||
<path d="M135.148 6.11093L134.402 5.64722V6.08144L134.4 5.64722L133.655 6.11093C132.085 7.08792 131.069 8.11093 129.847 9.54692L132.009 11.3664C132.848 10.3811 133.534 9.65784 134.402 9.01241C135.271 9.65902 135.957 10.3811 136.796 11.3664L138.958 9.54692C137.735 8.11093 136.72 7.08792 135.15 6.11093H135.148Z" fill="#313130"></path>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient gradientUnits="userSpaceOnUse" id="paint0_linear_1761_2880" x1="10.5893" x2="32.5084" y1="25.4902" y2="25.4902">
|
||||
<stop stop-color="#178BFF"></stop>
|
||||
<stop offset="1" stop-color="#0079DD"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient gradientUnits="userSpaceOnUse" id="paint1_linear_1761_2880" x1="12.6735" x2="6.70572" y1="6.32577" y2="26.2479">
|
||||
<stop stop-color="#4F33DB"></stop>
|
||||
<stop offset="1" stop-color="#311EC1"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient gradientUnits="userSpaceOnUse" id="paint2_linear_1761_2880" x1="26.9722" x2="14.4113" y1="15.1162" y2="-3.83177">
|
||||
<stop stop-color="#00D1D1"></stop>
|
||||
<stop offset="1" stop-color="#00AFAA"></stop>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_1761_2880">
|
||||
<rect fill="white" height="34" width="178"></rect>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 5.1 KiB |