Merge master into main

This commit is contained in:
Olivier CROGUENNEC
2026-06-13 17:52:55 +02:00
209 changed files with 49998 additions and 2 deletions
+25
View File
@@ -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
+40
View File
@@ -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/
+378
View File
@@ -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`.
+384
View File
@@ -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.
+191 -2
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
node_modules
data
uploads
.env
.env.*
npm-debug.log
*.log
+16
View File
@@ -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"]
+72
View File
@@ -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');
+67
View File
@@ -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();
+2296
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -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"
}
}
File diff suppressed because it is too large Load Diff
+12
View File
@@ -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();
+253
View File
@@ -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);
+124
View File
@@ -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)`);
}
+35
View File
@@ -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();
}
+23
View File
@@ -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();
}
+373
View File
@@ -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); }
});
+281
View File
@@ -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;
+133
View File
@@ -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;
+102
View File
@@ -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;
+65
View File
@@ -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;
+59
View File
@@ -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;
+96
View File
@@ -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;
+884
View File
@@ -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;
+118
View File
@@ -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;
+135
View File
@@ -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;
+315
View File
@@ -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;
+531
View File
@@ -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;
+457
View File
@@ -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;
+126
View File
@@ -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;
+135
View File
@@ -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;
+66
View File
@@ -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;
+70
View File
@@ -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;
+693
View File
@@ -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;
+62
View File
@@ -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;
+97
View File
@@ -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;
+97
View File
@@ -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;
File diff suppressed because it is too large Load Diff
+60
View File
@@ -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;
+89
View File
@@ -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;
+467
View File
@@ -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;
+100
View File
@@ -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;
+171
View File
@@ -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;
+125
View File
@@ -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;
+421
View File
@@ -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;
+106
View File
@@ -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();
});
+572
View File
@@ -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();
}
+157
View File
@@ -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)"
1 Champ référentiel Champ plateformes Hérité Notes
2 id id Non Clés primaires indépendantes
3 nom nom Oui HERITABLE_FIELDS
4 url url Non Présent dans les deux; pas hérité
5 description --” Référentiel seulement
6 annee_creation --” Référentiel seulement
7 type_investissement --” Référentiel seulement
8 secteur --” Référentiel seulement (remplacé par categories_inv/secteurs_inv côté user)
9 investisseurs_types --” Référentiel seulement
10 domiciliation domiciliation Oui HERITABLE_FIELDS
11 fiscalite fiscalite Oui HERITABLE_FIELDS
12 taux_fiscalite_locale taux_fiscalite_locale Oui HERITABLE_FIELDS
13 type_produit_fiscal type_produit_fiscal Oui HERITABLE_FIELDS
14 logo_filename logo_filename Oui HERITABLE_FIELDS
15 icone_filename icone_filename Oui HERITABLE_FIELDS
16 regulateur --” Référentiel seulement
17 numero_licence --” Référentiel seulement
18 is_regule --” Référentiel seulement
19 pays_inscription --” Référentiel seulement
20 pays_siege --” Référentiel seulement
21 pays_operation --” Référentiel seulement
22 investissement_minimum --” Référentiel seulement
23 rendement_annonce --” Référentiel seulement
24 nb_investisseurs --” Référentiel seulement
25 volume_total_finance --” Référentiel seulement
26 duree_moyenne_pret --” Référentiel seulement
27 garantie_rachat --” Référentiel seulement
28 statistiques_publiques --” Référentiel seulement
29 bonus_inscription --” Référentiel seulement
30 marche_secondaire --” Référentiel seulement
31 investissement_auto --” Référentiel seulement
32 url_trustpilot --” Référentiel seulement
33 url_linkedin --” Référentiel seulement
34 created_at created_at Non Présent dans les deux; timestamps indépendants
35 updated_at --” Référentiel seulement (plateformes n'a pas updated_at)
36 user_id --” Plateformes seulement --” lien vers l'utilisateur
37 notes --” Plateformes seulement --” notes libres
38 methode_remboursement --” Plateformes seulement
39 investisseur_id --” Plateformes seulement --” détenteur
40 date_ouverture --” Plateformes seulement
41 type_pret_defaut --” Plateformes seulement --” valeur par défaut formulaire
42 duree_defaut --” Plateformes seulement --” valeur par défaut formulaire
43 taux_defaut --” Plateformes seulement --” valeur par défaut formulaire
44 freq_interets_defaut --” Plateformes seulement --” valeur par défaut formulaire
45 referentiel_id --” Plateformes seulement --” lien vers le référentiel
46 overridden_fields --” Plateformes seulement --” champs surchargés (JSON)
+89
View File
@@ -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"
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

+168
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

+348
View File
@@ -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

+346
View File
@@ -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

+102
View File
@@ -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

+206
View File
@@ -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

+321
View File
@@ -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

+176
View File
@@ -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

+117
View File
@@ -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

+126
View File
@@ -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

+102
View File
@@ -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

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 68 KiB

+437
View File
@@ -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

+259
View File
@@ -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 23 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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 806 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 680 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

+16
View File
@@ -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

+59
View File
@@ -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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.1 KiB

Some files were not shown because too many files have changed in this diff Show More