diff --git a/MEMORY.md b/MEMORY.md
index 33802b7..a932f6e 100644
--- a/MEMORY.md
+++ b/MEMORY.md
@@ -459,3 +459,46 @@ const isBonus = BONUS_VALUES.includes(form.investissement_id);
1. `admin.js` (`POST /users`) et `invitations.js` (`POST /:token/register`) répliquent maintenant exactement la logique de `auth.js` : `INSERT INTO investisseurs (..., is_principal) VALUES (..., 1)` + `INSERT INTO comptes (user_id, nom, type, investisseur_id)` avec `nom = 'Compte courant — ' + fullName`
2. Backfill idempotent ajouté en fin de `backend/src/db/index.js` (avant `export default db`) qui tourne à chaque démarrage : (1) crée un investisseur principal pour tout user qui n'en a aucun, (2) marque principal le plus ancien investisseur `famille` pour tout user qui n'a pas de principal, (3) crée le compte courant manquant pour tout investisseur principal qui n'en a pas. Toutes les requêtes utilisent `NOT EXISTS` → sans effet une fois les données corrigées.
- **Règle à retenir** : toute nouvelle voie de création de compte utilisateur doit répliquer les 2 inserts de `auth.js` (`investisseurs` avec `is_principal=1` + `comptes` type `compte_courant`) — ne pas dupliquer seulement l'insert `investisseurs`.
+
+---
+
+## Session 9 — Import de données & cohérence mono/multi-détenteur (2026-07-04)
+
+### Ajout de plateforme depuis le référentiel — un seul champ demandé
+- `PlateformesSection.jsx` (`PlatPickerModal`) : au clic sur une plateforme du référentiel, une vue de confirmation demande **uniquement** la date d'ouverture du compte (« Pouvez-vous SVP préciser la date d'ouverture de votre compte sur cette plateforme ? »), pré-remplie à la date du jour (`localToday()` — jamais `.toISOString()`, cf. piège timezone ci-dessous). Tout le reste (domiciliation, fiscalité…) vient du référentiel.
+
+### Colonne "Détenteur" — masquée si mono-détenteur
+- Pattern à répliquer partout : `const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1;` (ou `investisseurs.length > 1` si pas de liste de plateformes disponible)
+- Déployé sur : `Investissements.jsx`, `Remboursements.jsx`, `Plateformes.jsx`, `PlateformesSection.jsx`, `ComptesSection.jsx`, `DepotsRetraits.jsx` — colonnes `
`/`
` conditionnées + `colSpan` ajusté + tfoot spacer
+- **Idem sur les exports** (XLS/CSV/JSON) : passer `multiDetenteur` en paramètre à la fonction d'export (ex. `mouvToXLS(rows, multiDetenteur)`) et spreader conditionnellement `...(multiDetenteur ? { 'Détenteur': ... } : {})`
+
+### DepotsRetraits — bug empty-state après premier dépôt (plateforme flat_tax)
+- **Cause** : pour une plateforme `flat_tax`, `submit()` ouvre `corrModal` (vérification du solde déclaré) au lieu d'appeler `load()` directement ; l'early-return de l'empty-state (`!loading && !modalOpen && allRows.length === 0`) masquait la `` rendue plus bas dans le JSX.
+- **Fix** : ajouter `!corrModal.open` à la condition de l'early-return.
+
+### Import — résolution de noms (plateforme/investissement) au lieu d'ID stricts
+- Backend `imports.js` : `resolveRefId(value, idSet, nameMap, label)` accepte un ID numérique OU un nom texte (normalisé accents/casse via `normalizeName`) pour `plateforme_id` (modules `depots_retraits`/`investissements`) et `investissement_id` (module `remboursements`)
+- Frontend : auto-mapping des colonnes par synonymes (`FIELD_SYNONYMS` + `normalizeHeader()`) en plus du match exact ; **auto-remplissage** du champ "Valeur par défaut" quand toutes les lignes d'échantillon résolvent vers la même plateforme/investissement (`resolvePreviewMatch`)
+
+### Import — protection anti-doublon
+- Clé de détection par module (avant insertion, dans la transaction) :
+ - `depots_retraits` : investisseur + plateforme + date + type + montant (tolérance 0,005)
+ - `investissements` : investisseur + plateforme + nom_projet + date_souscription
+ - `remboursements` : investissement + date_remb + capital + interets_bruts (tolérance 0,005)
+ - `plateformes` : déjà couvert par `UNIQUE(nom)` → reclassé en doublon plutôt qu'erreur
+- Compteur `duplicates` distinct de `skipped` (vraies erreurs), colonne DB `imports.rows_duplicates`, affiché dans l'historique
+
+### Import — anomalie date antérieure à la date d'ouverture de la plateforme
+- Pour `depots_retraits`/`investissements` uniquement (pas `remboursements`, lien plateforme trop indirect) : la date la plus ancienne rencontrée par plateforme dans le fichier importé (doublon ou non) est comparée à `plateformes.date_ouverture`
+- Si anomalie : réponse `/apply` inclut `anomalies: [{ plateforme_id, plateforme_nom, date_ouverture_actuelle, date_detectee }]`
+- Nouvelle route `PATCH /api/plateformes/:id/date-ouverture` (payload minimal `{ date_ouverture }`, ne PAS repasser par le `PUT /:id` complet)
+- `ImportsSection.jsx` affiche une bannière par anomalie avec bouton "Corriger la date d'ouverture (JJ/MM/AAAA)"
+
+### Import généré par IA — nouveau bloc dans ImportsSection.jsx
+- Bloc "Import généré par IA" : prompt dynamique (`DEFAULT_IA_IMPORT_PROMPT`, template `{{MODULE_LABEL}}`/`{{FIELDS_LIST}}`/`{{REFERENCE_SECTION}}`, éditable et persisté `localStorage`) listant les champs attendus du module cible + les plateformes/investissements existants ; l'utilisateur colle le JSON généré par l'IA (avec ou sans fence ```json```) qui est réinjecté dans le pipeline preview/apply existant via un `Blob`/`File` virtuel (pas de code dupliqué)
+- **Règle imposée au prompt** : si le fichier ne permet pas d'identifier avec certitude la plateforme (ou l'investissement pour les remboursements), l'IA doit poser la question à l'utilisateur en ne proposant QUE les noms existants comme réponses possibles, et attendre la réponse avant de générer le JSON — plutôt que de deviner ou d'omettre le champ silencieusement
+- Page restructurée en 3 blocs séquentiels : "1. Contexte de l'import" (sélecteur de module) → "2. Fichier source" → "3. Mappage des colonnes" ; le bloc "Dossier investissement" n'apparaît que si le module actif est `investissements`
+
+### Piège outillage — mount bash périmé après édition Edit/Write
+- Après une édition via l'outil `Edit`/`Write` (côté fichier réel), une vérification immédiate via `mcp__workspace__bash` (wc/tail/node --check) peut montrer une **version périmée/tronquée** du fichier alors que le fichier réel est complet et correct — le mount bash ne se resynchronise pas instantanément après une écriture Windows-side.
+- **Règle** : vérifier l'intégrité d'un fichier édité via l'outil `Read` (relire la queue, vérifier la fermeture propre), pas via bash. N'utiliser bash pour vérifier que si l'écriture a elle-même été faite depuis bash (ex. reconstruction `python3` après troncature confirmée par `Read`).
diff --git a/backend/src/routes/imports.js b/backend/src/routes/imports.js
index 0a5c6fc..d80ee7e 100644
--- a/backend/src/routes/imports.js
+++ b/backend/src/routes/imports.js
@@ -139,6 +139,14 @@ router.post('/apply', (req, res, next) => {
let inserted = 0, skipped = 0, duplicates = 0;
const errors = [];
+ // Date la plus ancienne rencontrée par plateforme (dépôts/retraits + investissements)
+ // — sert à détecter une date antérieure à la date d'ouverture déclarée sur la plateforme.
+ const platMinDate = new Map();
+ const trackPlatDate = (plateformeId, dateStr) => {
+ if (!plateformeId || !dateStr) return;
+ const cur = platMinDate.get(plateformeId);
+ if (!cur || dateStr < cur) platMinDate.set(plateformeId, dateStr);
+ };
const tx = db.transaction(() => {
for (let idx = 0; idx < rows.length; idx++) {
@@ -157,6 +165,7 @@ router.post('/apply', (req, res, next) => {
const dateOperation = normaliseDate(v('date_operation'));
const type = normaliseType(v('type'));
const montant = num(v('montant'));
+ trackPlatDate(plateformeId, dateOperation);
// Anti-doublon : même investisseur + plateforme + date + type + montant
const dup = db.prepare(`
@@ -186,6 +195,7 @@ router.post('/apply', (req, res, next) => {
const plateformeId = resolveRefId(v('plateforme_id'), platIdSet, platNameMap, 'Plateforme');
const nomProjet = String(v('nom_projet'));
const dateSouscription = normaliseDate(v('date_souscription'));
+ trackPlatDate(plateformeId, dateSouscription);
// Anti-doublon : même investisseur + plateforme + nom du projet + date de souscription
const dup = db.prepare(`
@@ -295,6 +305,23 @@ router.post('/apply', (req, res, next) => {
});
tx();
+ // Anomalie : une date importée précède la date d'ouverture déclarée sur la plateforme
+ const anomalies = [];
+ if (platMinDate.size > 0) {
+ for (const [plateformeId, minDate] of platMinDate) {
+ const plat = db.prepare('SELECT id, nom, date_ouverture FROM plateformes WHERE id = ? AND user_id = ?')
+ .get(plateformeId, req.user.id);
+ if (plat && plat.date_ouverture && minDate < plat.date_ouverture) {
+ anomalies.push({
+ plateforme_id: plat.id,
+ plateforme_nom: plat.nom,
+ date_ouverture_actuelle: plat.date_ouverture,
+ date_detectee: minDate,
+ });
+ }
+ }
+ }
+
db.prepare(`
INSERT INTO imports (user_id, investisseur_id, module, filename, rows_total, rows_inserted, rows_skipped, rows_duplicates, mapping_json)
VALUES (?,?,?,?,?,?,?,?,?)
@@ -313,7 +340,7 @@ router.post('/apply', (req, res, next) => {
// Clean up temp file
try { fs.unlinkSync(tempPath); } catch { /* */ }
- res.json({ inserted, skipped, duplicates, total: rows.length, errors: errors.slice(0, 50) });
+ res.json({ inserted, skipped, duplicates, total: rows.length, errors: errors.slice(0, 50), anomalies });
} catch (e) { next(e); }
});
diff --git a/backend/src/routes/plateformes.js b/backend/src/routes/plateformes.js
index 98c2bd9..1826901 100644
--- a/backend/src/routes/plateformes.js
+++ b/backend/src/routes/plateformes.js
@@ -661,6 +661,18 @@ router.put('/:id', (req, res, next) => {
} catch (e) { next(e); }
});
+// ── Correction rapide de la date d'ouverture (ex. anomalie détectée à l'import) ──
+router.patch('/:id/date-ouverture', (req, res, next) => {
+ try {
+ const schema = z.object({ date_ouverture: z.string().regex(/^\d{4}-\d{2}-\d{2}$/) });
+ const { date_ouverture } = schema.parse(req.body);
+ const r = db.prepare('UPDATE plateformes SET date_ouverture = ? WHERE id = ? AND user_id = ?')
+ .run(date_ouverture, req.params.id, req.user.id);
+ if (r.changes === 0) throw new HttpError(404, 'Not found');
+ res.json({ id: Number(req.params.id), date_ouverture });
+ } catch (e) { next(e); }
+});
+
router.delete('/:id', (req, res, next) => {
try {
// Récupère le logo avant suppression pour effacer le fichier
diff --git a/frontend/src/pages/DepotsRetraits.jsx b/frontend/src/pages/DepotsRetraits.jsx
index 853e10e..64769d2 100644
--- a/frontend/src/pages/DepotsRetraits.jsx
+++ b/frontend/src/pages/DepotsRetraits.jsx
@@ -15,6 +15,12 @@ import DepotsMensuelTable from '../components/DepotsMensuelTable.jsx';
import * as XLSX from 'xlsx';
/* ── Helpers export ──────────────────────────────────────────── */
+/** Horodatage local AAAAMMJJ_HHmmss (jamais toISOString(), cf. décalage UTC) */
+function timestampSuffix() {
+ const d = new Date();
+ const pad = n => String(n).padStart(2, '0');
+ return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
+}
function dlBlob(content, filename, type) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
@@ -375,14 +381,16 @@ export default function DepotsRetraits() {
const [allRows, setAllRows] = useState([]);
const [plats, setPlats] = useState([]);
const [dashData, setDashData] = useState(null);
- const [filter, setFilter] = useState({ type: '', plateforme_ids: [], year: '', month: '' });
+ // Année sélectionnée, partagée entre l'onglet Plateformes et le filtre "Année" de Mouvements
+ const currentYear = String(new Date().getFullYear());
+ const [filter, setFilter] = useState({ type: '', plateforme_ids: [], year: currentYear, month: '' });
const [modalOpen, setModalOpen] = useState(false);
const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState(empty);
const [err, setErr] = useState(null);
const [loading, setLoading] = useState(false);
const [selectedRow, setSelectedRow] = useState(null);
- const [drPlatYear, setDrPlatYear] = useState(String(new Date().getFullYear()));
+ const [drPlatYear, setDrPlatYear] = useState(currentYear);
const [allRemb, setAllRemb] = useState([]);
const [allInv, setAllInv] = useState([]);
@@ -401,6 +409,7 @@ export default function DepotsRetraits() {
const platIds = p.get('plat_ids');
if (platIds) newFilter.plateforme_ids = platIds.split(',').filter(Boolean);
setFilter(newFilter);
+ setDrPlatYear(newFilter.year); // garde l'onglet Plateformes synchronisé avec l'année du drill-down
navigate('/depots-retraits', { replace: true });
}, []); // eslint-disable-line
const [openMenu, setOpenMenu] = useState(null); // { row, x, y }
@@ -905,9 +914,9 @@ export default function DepotsRetraits() {
<>
@@ -988,7 +997,7 @@ export default function DepotsRetraits() {
{drPlatYear && pour l'année {drPlatYear}}
-
@@ -1217,7 +1236,7 @@ export default function DepotsRetraits() {
- setFilter({ ...filter, year: e.target.value })}>
+ { const v = e.target.value; setFilter(f => ({ ...f, year: v })); setDrPlatYear(v); }}>
{years.map(y => )}
diff --git a/frontend/src/pages/settings/ImportsSection.jsx b/frontend/src/pages/settings/ImportsSection.jsx
index c2db01d..7920913 100644
--- a/frontend/src/pages/settings/ImportsSection.jsx
+++ b/frontend/src/pages/settings/ImportsSection.jsx
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from 'react';
-import { useLocation, useNavigate } from 'react-router-dom';
+import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import { api } from '../../api.js';
import { fmtDate } from '../../utils/format.js';
import { useInvestisseur } from '../../context/InvestisseurContext.jsx';
@@ -201,6 +201,230 @@ function resolvePreviewMatch(raw, refList, nameKey) {
return refList.find(x => normalizeName(x[nameKey]) === norm) || null;
}
+/* ══════════════════════════════════════════════════════════════
+ Import généré par IA — génération dynamique d'un prompt d'extraction
+ ══════════════════════════════════════════════════════════════ */
+
+/** Description humaine de chaque champ cible, réutilisée pour générer le prompt IA */
+const FIELD_HINTS = {
+ date_operation: 'date du mouvement, format ISO YYYY-MM-DD',
+ type: '"depot" ou "retrait"',
+ montant: 'montant en euros (nombre, ex: 500 ou 500.5 — pas de symbole €, point décimal)',
+ plateforme_id: 'nom EXACT de la plateforme (voir liste ci-dessous) ou son ID numérique',
+ libelle: 'libellé libre du mouvement',
+ reference: 'référence libre (n° de virement, etc.)',
+ nom_projet: 'nom du projet / du prêt',
+ date_souscription: 'date de souscription, format ISO YYYY-MM-DD',
+ montant_investi: 'montant investi en euros (nombre)',
+ emetteur: "nom de l'émetteur du projet, si disponible",
+ date_premiere_echeance: 'date de la première échéance, format ISO YYYY-MM-DD',
+ date_cible: 'date de fin prévue du prêt, format ISO YYYY-MM-DD',
+ taux_interet: "taux d'intérêt annuel en % (nombre, ex: 8.5)",
+ duree_mois: 'durée du prêt en mois (nombre entier)',
+ type_remb: '"in_fine" | "amortissable" | "differe"',
+ freq_interets: '"mensuel" | "trimestriel" | "in_fine"',
+ statut: '"en_cours" | "rembourse" | "en_retard" | "procedure" | "cloture"',
+ investissement_id: 'nom EXACT du projet (voir liste ci-dessous) ou son ID numérique',
+ date_remb: 'date du remboursement, format ISO YYYY-MM-DD',
+ capital: 'part de capital remboursée, en euros (nombre)',
+ cashback: 'montant de cashback perçu, en euros (nombre)',
+ interets_bruts: 'intérêts bruts perçus, en euros (nombre, avant prélèvements)',
+ prelev_sociaux: 'prélèvements sociaux, en euros (nombre)',
+ prelev_forfaitaire: "impôt sur le revenu (prélèvement forfaitaire), en euros (nombre)",
+ net_recu: 'montant net reçu, en euros (nombre)',
+ nom: 'nom de la plateforme',
+ url: 'URL du site de la plateforme',
+ notes: 'notes libres',
+ annee: 'année concernée (nombre entier, ex: 2024)',
+ pfu_total: 'taux PFU total en % (nombre, ex: 30)',
+ impot_revenu: "part impôt sur le revenu du taux PFU, en %",
+};
+
+/** Certains champs ont un sens différent selon le module (ex. prelev_sociaux : montant vs taux) */
+const FIELD_HINTS_OVERRIDE = {
+ taux_pfu: {
+ prelev_sociaux: 'taux de prélèvements sociaux en % (nombre, ex: 17.2)',
+ },
+};
+
+function buildFieldsList(mod) {
+ const def = MODULES[mod];
+ if (!def) return '';
+ const lines = [];
+ for (const key of def.required) {
+ const hint = FIELD_HINTS_OVERRIDE[mod]?.[key] ?? FIELD_HINTS[key] ?? '';
+ lines.push(`- ${key} (obligatoire)${hint ? ` : ${hint}` : ''}`);
+ }
+ for (const key of def.optional) {
+ const hint = FIELD_HINTS_OVERRIDE[mod]?.[key] ?? FIELD_HINTS[key] ?? '';
+ lines.push(`- ${key} (optionnel)${hint ? ` : ${hint}` : ''}`);
+ }
+ return lines.join('\n');
+}
+
+function buildReferenceSection(mod, plats, investissements) {
+ if ((mod === 'depots_retraits' || mod === 'investissements') && plats.length > 0) {
+ const names = plats.map(p => p.nom).join(' | ');
+ return `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nPLATEFORMES EXISTANTES (pour plateforme_id)\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nUtilise EXACTEMENT l'un de ces noms si la plateforme correspond à l'une d'elles :\n${names}\n\n⚠️ Si le fichier ne permet pas d'identifier avec certitude de quelle plateforme il provient (nom absent du fichier, logo/en-tête ambigu, etc.), NE DEVINE PAS et NE GÉNÈRE PAS le JSON tout de suite : pose-moi d'abord la question « De quelle plateforme provient ce fichier ? » en ne me proposant QUE ces réponses possibles :\n${names}\nAttends ma réponse, puis utilise le nom choisi comme valeur de plateforme_id pour TOUTES les lignes générées.\n\n`;
+ }
+ if (mod === 'remboursements' && investissements.length > 0) {
+ const names = investissements.map(i => i.nom_projet).join(' | ');
+ return `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nINVESTISSEMENTS EXISTANTS (pour investissement_id)\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\nUtilise EXACTEMENT l'un de ces noms de projet si le remboursement correspond à l'un d'eux :\n${names}\n\n⚠️ Si le fichier ne permet pas d'identifier avec certitude à quel investissement une ligne correspond, NE DEVINE PAS et NE GÉNÈRE PAS le JSON tout de suite : pose-moi d'abord la question « À quel investissement correspond ce fichier ? » en ne me proposant QUE ces réponses possibles :\n${names}\nAttends ma réponse, puis utilise le nom choisi comme valeur d'investissement_id pour TOUTES les lignes générées.\n\n`;
+ }
+ return '';
+}
+
+const DEFAULT_IA_IMPORT_PROMPT = `Tu es un assistant spécialisé dans la conversion de données financières pour un tracker de portefeuille de crowdlending (financement participatif).
+
+━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+FICHIER À ANALYSER
+━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+Analyse le fichier d'export que je vais joindre (relevé téléchargé depuis ma plateforme de crowdlending — CSV, Excel ou PDF), et convertis chaque ligne pertinente en un objet JSON.
+
+━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+MODULE CIBLE : {{MODULE_LABEL}}
+━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+Chaque objet du tableau doit utiliser EXACTEMENT ces clés (telles quelles, en minuscules) :
+
+{{FIELDS_LIST}}
+
+{{REFERENCE_SECTION}}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+FORMAT DE SORTIE
+━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+Retourne UNIQUEMENT un tableau JSON, une entrée par ligne du fichier, sans texte ni explication autour, par exemple :
+[
+ { ... },
+ { ... }
+]
+
+━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+INSTRUCTIONS
+━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+1. N'invente aucune donnée absente du fichier — omets un champ optionnel si l'information n'est pas disponible.
+2. Toutes les dates sont au format ISO YYYY-MM-DD.
+3. Tous les montants sont des nombres (pas de texte, pas de symbole €, point décimal et non virgule).
+4. N'inclus que les lignes correspondant à de vraies opérations (ignore en-têtes, totaux, lignes vides).
+5. Si une section ci-dessus t'indique de poser une question avant de générer le JSON, respecte-la strictement : ne produis le tableau JSON qu'après ma réponse.`;
+
+/** Bloc collapsible : coller le JSON généré par l'IA et lancer l'analyse (réutilise le pipeline existant) */
+function IaImportPasteBlock({ moduleLabel, iaJson, setIaJson, iaErr, setIaErr, onAnalyze, busy }) {
+ const [open, setOpen] = useState(true);
+ return (
+