diff --git a/backend/src/db/index.js b/backend/src/db/index.js index 5934441..71f776a 100644 --- a/backend/src/db/index.js +++ b/backend/src/db/index.js @@ -464,6 +464,21 @@ db.exec('CREATE INDEX IF NOT EXISTS idx_revisions_inv ON investissement_revision } } +// ── Migration : traçage du statut lors d'une révision (fonctionnalité Prolongation) ── +// Quand une révision change la date cible d'un dossier en_retard/procedure, le statut +// bascule automatiquement à 'prolongation' (cf. routes/investissements.js). On trace +// l'ancien et le nouveau statut ici, comme ancien_taux/nouveau_taux, pour permettre un +// rollback fidèle (DELETE /revisions/:rid restaure ancien_statut). +{ + const revColsStatut = db.prepare('PRAGMA table_info(investissement_revisions)').all().map(c => c.name); + if (!revColsStatut.includes('ancien_statut')) { + db.exec('ALTER TABLE investissement_revisions ADD COLUMN ancien_statut TEXT'); + } + if (!revColsStatut.includes('nouveau_statut')) { + db.exec('ALTER TABLE investissement_revisions ADD COLUMN nouveau_statut TEXT'); + } +} + // ── Migration : rôle utilisateur ───────────────────────────────────────── { const userCols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name); @@ -2381,4 +2396,140 @@ db.exec('CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash)'); } } +// ── Migration : ajout du statut 'perte_definitive' ────────────────────── +// Nouvelle valeur d'enum pour déclarer explicitement qu'un capital est +// définitivement perdu (irrécouvrable), distincte de 'en_retard'/'procedure' +// qui restent potentiellement recouvrables. SQLite ne permet pas d'altérer un +// CHECK constraint : on recrée la table avec le nouveau CHECK, même stratégie +// que la migration 'defaut' → 'en_retard' plus haut. Placé en toute fin de +// fichier (après toutes les migrations ADD COLUMN de la table investissements) +// pour que PRAGMA table_info reflète bien la totalité des colonnes, y compris +// sur une base fraîchement créée depuis schema.sql. +{ + const schemaInvPerte = db.prepare( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='investissements'" + ).get()?.sql ?? ''; + + if (schemaInvPerte && !schemaInvPerte.includes("'perte_definitive'")) { + const tempName = '__repair_investissements_perte'; + + // Le nom de table peut être stocké avec ou sans guillemets dans + // sqlite_master.sql selon qu'une précédente migration (ex. 'defaut' → + // 'en_retard') a déjà renommé la table via ALTER TABLE ... RENAME TO + // (SQLite conserve alors le nom entre guillemets dans le DDL stocké) — + // le regex doit couvrir les deux formes, sinon le remplacement échoue + // silencieusement et fixedDdl retente de créer "investissements" alors + // qu'elle existe déjà (SqliteError: table "investissements" already exists). + const fixedDdl = schemaInvPerte + .replace(/CREATE TABLE\s+"?investissements"?/, `CREATE TABLE "${tempName}"`) + .replace(",'cloture'))", ",'cloture','perte_definitive'))"); + + if (fixedDdl.includes(`"${tempName}"`) && fixedDdl.includes("'perte_definitive'")) { + const colDefs = db.prepare('PRAGMA table_info(investissements)').all(); + const colNames = colDefs.map(c => `"${c.name}"`).join(', '); + + const idxs = db.prepare( + "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='investissements' AND sql IS NOT NULL" + ).all(); + + db.exec('PRAGMA foreign_keys = OFF'); + db.exec(`DROP TABLE IF EXISTS "${tempName}"`); + db.exec(fixedDdl); + db.exec(`INSERT INTO "${tempName}" (${colNames}) SELECT ${colNames} FROM investissements`); + for (const idx of idxs) db.exec(`DROP INDEX IF EXISTS "${idx.name}"`); + db.exec('DROP TABLE investissements'); + // legacy_alter_table = ON : empêche la réécriture automatique des FK enfants + db.exec('PRAGMA legacy_alter_table = ON'); + db.exec(`ALTER TABLE "${tempName}" RENAME TO investissements`); + db.exec('PRAGMA legacy_alter_table = OFF'); + db.exec('PRAGMA foreign_keys = ON'); + + db.exec('CREATE INDEX IF NOT EXISTS idx_inv_inv ON investissements(investisseur_id)'); + db.exec('CREATE INDEX IF NOT EXISTS idx_inv_plat ON investissements(plateforme_id)'); + db.exec('CREATE INDEX IF NOT EXISTS idx_inv_statut ON investissements(statut)'); + db.exec('CREATE INDEX IF NOT EXISTS idx_inv_date ON investissements(date_souscription)'); + + console.log("[DB] investissements: statut 'perte_definitive' ajouté au CHECK constraint"); + } else { + console.error("[DB] Migration perte_definitive : nom de table ou motif ',cloture))' introuvable dans le DDL — migration ignorée, à vérifier manuellement"); + } + } +} + +// ── Migration : table investissement_pertes ────────────────────────────── +// Trace chaque déclaration de perte définitive : date de constat, montant +// perdu (préfilli côté frontend avec le capital restant dû mais éditable pour +// une perte partielle si une partie du capital a déjà été recouvrée) et motif +// obligatoire. Distincte de investissement_historique (piste d'audit générique +// auto-détectée) : ici on conserve la donnée structurée exploitée par le +// calcul fiscal (case 2TY) et par les KPI de capital investi/restant dû. +db.exec(` + CREATE TABLE IF NOT EXISTS investissement_pertes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + investissement_id INTEGER NOT NULL REFERENCES investissements(id) ON DELETE CASCADE, + date_effet TEXT NOT NULL, + montant_perte REAL NOT NULL CHECK(montant_perte >= 0), + ancien_statut TEXT, + motif TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) +`); +db.exec('CREATE INDEX IF NOT EXISTS idx_pertes_inv ON investissement_pertes(investissement_id)'); + +// ── Migration : ajout du statut 'prolongation' ────────────────────────── +// Nouvelle valeur d'enum posée automatiquement par POST /revisions quand une révision +// des conditions change la date cible d'un dossier en_retard/procedure (renégociation +// avec échéancier modifié) : le dossier sort temporairement du scope "à risque" jusqu'à +// ce que la nouvelle date cible soit à son tour dépassée (cf. jobs/autoStatut.js qui le +// repasse alors en_retard). Même stratégie de recréation de table que 'perte_definitive' +// ci-dessus (SQLite ne permet pas d'altérer un CHECK constraint). Placé après cette +// dernière migration pour bénéficier du même schemaInv déjà à jour (CHECK contenant +// déjà 'perte_definitive' à ce stade, sur une base existante comme sur une base neuve). +{ + const schemaInvProlong = db.prepare( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='investissements'" + ).get()?.sql ?? ''; + + if (schemaInvProlong && !schemaInvProlong.includes("'prolongation'")) { + const tempName = '__repair_investissements_prolongation'; + + // Cf. migration perte_definitive plus haut : le nom de table peut être stocké + // avec ou sans guillemets dans sqlite_master.sql selon l'historique des ALTER + // TABLE ... RENAME TO déjà exécutés — le regex doit couvrir les deux formes. + const fixedDdl = schemaInvProlong + .replace(/CREATE TABLE\s+"?investissements"?/, `CREATE TABLE "${tempName}"`) + .replace(",'perte_definitive'))", ",'perte_definitive','prolongation'))"); + + if (fixedDdl.includes(`"${tempName}"`) && fixedDdl.includes("'prolongation'")) { + const colDefs = db.prepare('PRAGMA table_info(investissements)').all(); + const colNames = colDefs.map(c => `"${c.name}"`).join(', '); + + const idxs = db.prepare( + "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='investissements' AND sql IS NOT NULL" + ).all(); + + db.exec('PRAGMA foreign_keys = OFF'); + db.exec(`DROP TABLE IF EXISTS "${tempName}"`); + db.exec(fixedDdl); + db.exec(`INSERT INTO "${tempName}" (${colNames}) SELECT ${colNames} FROM investissements`); + for (const idx of idxs) db.exec(`DROP INDEX IF EXISTS "${idx.name}"`); + db.exec('DROP TABLE investissements'); + // legacy_alter_table = ON : empêche la réécriture automatique des FK enfants + db.exec('PRAGMA legacy_alter_table = ON'); + db.exec(`ALTER TABLE "${tempName}" RENAME TO investissements`); + db.exec('PRAGMA legacy_alter_table = OFF'); + db.exec('PRAGMA foreign_keys = ON'); + + db.exec('CREATE INDEX IF NOT EXISTS idx_inv_inv ON investissements(investisseur_id)'); + db.exec('CREATE INDEX IF NOT EXISTS idx_inv_plat ON investissements(plateforme_id)'); + db.exec('CREATE INDEX IF NOT EXISTS idx_inv_statut ON investissements(statut)'); + db.exec('CREATE INDEX IF NOT EXISTS idx_inv_date ON investissements(date_souscription)'); + + console.log("[DB] investissements: statut 'prolongation' ajouté au CHECK constraint"); + } else { + console.error("[DB] Migration prolongation : nom de table ou motif \",'perte_definitive'))\" introuvable dans le DDL — migration ignorée, à vérifier manuellement"); + } + } +} + export default db; diff --git a/backend/src/db/schema.sql b/backend/src/db/schema.sql index 15e335a..01fc4bd 100644 --- a/backend/src/db/schema.sql +++ b/backend/src/db/schema.sql @@ -87,7 +87,7 @@ 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' +-- statut: 'en_cours' | 'rembourse' | 'en_retard' | 'procedure' | 'cloture' | 'perte_definitive' | 'prolongation' -- --------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS investissements ( id INTEGER PRIMARY KEY, @@ -104,7 +104,7 @@ CREATE TABLE IF NOT EXISTS investissements ( 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')), + CHECK(statut IN ('en_cours','rembourse','en_retard','procedure','cloture','perte_definitive','prolongation')), reference TEXT, -- ID projet sur la plateforme source TEXT NOT NULL DEFAULT 'manuel', notes TEXT, diff --git a/backend/src/jobs/autoStatut.js b/backend/src/jobs/autoStatut.js index 77f258d..aa1be1a 100644 --- a/backend/src/jobs/autoStatut.js +++ b/backend/src/jobs/autoStatut.js @@ -28,9 +28,14 @@ function notifyUser(userId, { type, title, body, link }) { /** * Passe automatiquement au statut "en_retard" les investissements dont : - * - le statut est actuellement "en_cours" + * - le statut est actuellement "en_cours" OU "prolongation" * - la date_cible est renseignée et strictement antérieure à aujourd'hui * + * Le cas "prolongation" couvre un dossier dont la date cible avait été repoussée via une + * révision des conditions (renégociation suite à un retard) et qui retombe en retard parce + * que la nouvelle échéance négociée est à son tour dépassée sans remboursement — cf. + * POST /investissements/:id/revisions qui pose le statut 'prolongation'. + * * Chaque passage est tracé dans investissement_historique avec le * type_evenement 'passage_auto_retard' pour conserver l'auditabilité, et * génère une notification utilisateur. @@ -39,10 +44,10 @@ function notifyUser(userId, { type, title, body, link }) { */ function checkPassagesEnRetard() { const candidats = db.prepare(` - SELECT i.id, i.nom_projet, i.date_cible, inv.user_id + SELECT i.id, i.nom_projet, i.date_cible, i.statut, inv.user_id FROM investissements i JOIN investisseurs inv ON inv.id = i.investisseur_id - WHERE i.statut = 'en_cours' + WHERE i.statut IN ('en_cours', 'prolongation') AND i.date_cible IS NOT NULL AND i.date_cible < date('now') `).all(); @@ -69,15 +74,19 @@ function checkPassagesEnRetard() { JSON.stringify([{ champ: 'statut', label: 'Statut', - ancienne_valeur: 'en_cours', + ancienne_valeur: inv.statut, nouvelle_valeur: 'en_retard', }]), - `Passage automatique : date cible (${inv.date_cible}) dépassée` + inv.statut === 'prolongation' + ? `Passage automatique : la nouvelle échéance négociée (${inv.date_cible}) est à son tour dépassée` + : `Passage automatique : date cible (${inv.date_cible}) dépassée` ); notifyUser(inv.user_id, { type: 'warning', title: `Prêt en retard : "${inv.nom_projet}"`, - body: `La date cible (${inv.date_cible}) est dépassée sans remboursement enregistré. Le prêt est passé automatiquement au statut "En retard".`, + body: inv.statut === 'prolongation' + ? `La date cible négociée (${inv.date_cible}) est dépassée sans remboursement enregistré. Le prêt repasse automatiquement au statut "En retard".` + : `La date cible (${inv.date_cible}) est dépassée sans remboursement enregistré. Le prêt est passé automatiquement au statut "En retard".`, link: `/investissements/${inv.id}`, }); } diff --git a/backend/src/routes/dashboard.js b/backend/src/routes/dashboard.js index 82260f0..5cd34dc 100644 --- a/backend/src/routes/dashboard.js +++ b/backend/src/routes/dashboard.js @@ -704,7 +704,9 @@ router.get('/interets-par-plateforme', (req, res) => { 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 + (SELECT MAX(r.date_remb) FROM remboursements r WHERE r.investissement_id = i.id) AS last_remb_date, + (SELECT ip.date_effet FROM investissement_pertes ip WHERE ip.investissement_id = i.id ORDER BY ip.id DESC LIMIT 1) AS perte_date_effet, + (SELECT ip.montant_perte FROM investissement_pertes ip WHERE ip.investissement_id = i.id ORDER BY ip.id DESC LIMIT 1) AS perte_montant FROM investissements i WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')} `).all(...invParams); @@ -792,10 +794,22 @@ router.get('/interets-par-plateforme', (req, res) => { .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); + // 'prolongation' compte normalement comme capital encours (comme en_retard/procedure + // dont il découle après renégociation) mais n'entre pas dans en_defaut : il est sorti + // temporairement du scope "à risque" tant que la nouvelle échéance n'est pas dépassée. + const actif = ['en_cours', 'en_retard', 'procedure', 'prolongation'].includes(inv.statut); if (actif) { capital += capitalRestant; if (['en_retard', 'procedure'].includes(inv.statut)) enDefaut += capitalRestant; + } else if (inv.statut === 'perte_definitive' && inv.perte_date_effet) { + // Avant la déclaration de perte (fidélité historique) : capital compté normalement, + // comme si le dossier était encore en_retard/procedure ce mois-là. À partir du mois + // de déclaration : capital réduit du montant acté en perte (perte partielle possible). + if (lastDay < inv.perte_date_effet) { + capital += capitalRestant; + } else { + capital += Math.max(0, capitalRestant - (inv.perte_montant || 0)); + } } else { // Remboursé/clôturé : était-il encore actif ce mois-ci ? // Priorité : date_cible → dernier remboursement réel → exclure diff --git a/backend/src/routes/investissements.js b/backend/src/routes/investissements.js index aba2a28..f9e54a5 100644 --- a/backend/src/routes/investissements.js +++ b/backend/src/routes/investissements.js @@ -3,7 +3,7 @@ import { z } from 'zod'; import db from '../db/index.js'; import { HttpError } from '../middleware/errorHandler.js'; import { requireInvestisseur } from '../middleware/investisseurScope.js'; -import { generateSimul, generateSimulWithReinvestissements, monthsDiff } from '../utils/schedule.js'; +import { generateSimul, generateSimulWithReinvestissements, monthsDiff, adjustSimulForActuals, replayAdjustSimulForActuals } from '../utils/schedule.js'; import { checkStatutsRetard } from '../jobs/autoStatut.js'; const router = Router(); @@ -61,20 +61,53 @@ const RevisionSchema = z.object({ message: 'Au moins un changement (nouveau taux ou nouvelle date cible) doit être renseigné', }); +// Déclaration d'une perte définitive de capital sur un investissement en_retard/procedure. +// montant_perte est éditable (perte partielle possible si une partie du capital a déjà +// été recouvrée) — préfilli côté frontend avec le capital restant dû, non recalculé ici. +const PerteSchema = z.object({ + date_effet: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + montant_perte: z.number().nonnegative(), + motif: z.string().trim().min(1, 'Le motif est obligatoire'), +}); + function regenererEcheancier(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 hasReinvest = db.prepare( 'SELECT 1 FROM reinvestissements WHERE investissement_id = ? LIMIT 1' ).get(investissementId); if (hasReinvest) { generateSimulWithReinvestissements(db, investissementId); } else { - const inv = db.prepare(` - SELECT id, montant_investi, taux_interet, duree_mois, type_remb, freq_interets, - date_premiere_echeance, date_debut_simul, date_souscription, echeance_fin_de_mois - FROM investissements WHERE id = ? - `).get(investissementId); generateSimul(db, inv); } + + // generateSimul()/generateSimulWithReinvestissements() reconstruisent l'échéancier à partir + // du montant_investi ORIGINAL, sans connaître les remboursements anticipés partiels déjà + // effectués — il faut donc systématiquement réappliquer l'ajustement de capital réel après + // toute régénération, sous peine de l'écraser silencieusement : + // - Restructuration (date_debut_simul renseigné, création d'une révision) : seules de + // NOUVELLES échéances futures sont insérées, les échéances déjà honorées ("kept") sont + // préservées telles quelles par generateSimul() lui-même -> un simple adjustSimulForActuals() + // suffit à recalculer ces nouvelles échéances sur la base du capital réellement restant dû. + // - Régénération complète (annulation d'une révision, ou toute autre modif hors + // restructuration) : TOUT l'échéancier est réécrit à neuf -> adjustSimulForActuals() seul + // ne peut PAS reconstruire l'historique (il ne recalcule que ce qui suit le DERNIER + // remboursement connu, jamais une valeur intermédiaire déjà "gelée" par un remboursement + // plus récent) -> il faut rejouer chronologiquement tout l'historique de remboursements + // de capital anticipés via replayAdjustSimulForActuals(). + // Bug découvert le 22/08/26 sur le dossier "L'Olympique" : l'annulation d'une révision avait + // effacé l'intégralité de l'ajustement de capital anticipé accumulé sur 18 échéances. + if (inv.date_debut_simul) { + adjustSimulForActuals(db, investissementId); + } else { + replayAdjustSimulForActuals(db, investissementId); + } } const Schema = z.object({ @@ -135,6 +168,8 @@ router.get('/', (req, res) => { (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, + (SELECT COALESCE(SUM(ip.montant_perte),0) FROM investissement_pertes ip WHERE ip.investissement_id = i.id) AS capital_perdu, + (SELECT ip.date_effet FROM investissement_pertes ip WHERE ip.investissement_id = i.id ORDER BY ip.id DESC LIMIT 1) AS perte_date_effet, 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 @@ -425,6 +460,9 @@ router.get('/:id', (req, res, next) => { const revisions = db.prepare( 'SELECT * FROM investissement_revisions WHERE investissement_id = ? ORDER BY id ASC' ).all(req.params.id); + const pertes = db.prepare( + 'SELECT * FROM investissement_pertes WHERE investissement_id = ? ORDER BY id ASC' + ).all(req.params.id); const reinvestissements = db.prepare( 'SELECT * FROM reinvestissements WHERE investissement_id = ? ORDER BY date_reinvestissement' ).all(req.params.id); @@ -445,7 +483,7 @@ router.get('/:id', (req, res, next) => { WHERE is2.investissement_id = ? ORDER BY is_global DESC, s.nom `).all(req.params.id); - res.json({ ...inv, capital_total, reinvestissements_total, remboursements, simul, historique, revisions, reinvestissements, categories_inv, secteurs_inv }); + res.json({ ...inv, capital_total, reinvestissements_total, remboursements, simul, historique, revisions, pertes, reinvestissements, categories_inv, secteurs_inv }); } catch (e) { next(e); } }); @@ -611,6 +649,10 @@ router.delete('/:id/historique/:hid', (req, res, next) => { // qui n'est qu'un champ d'affichage/contractuel). Donc si nouvelle_date_cible est fournie et // diffère de la date actuelle, duree_mois doit être recalculé en conséquence, sinon la // nouvelle date cible ne serait que cosmétique et l'échéancier généré ne la refléterait pas. +// +// Statut "Prolongation" : si la date cible change réellement sur un dossier en_retard/procedure, +// le statut bascule automatiquement à 'prolongation' (sort temporairement du scope "à risque", +// jusqu'à ce que jobs/autoStatut.js le repasse en_retard si la nouvelle échéance est dépassée). router.post('/:id/revisions', (req, res, next) => { try { const body = RevisionSchema.parse(req.body); @@ -631,27 +673,41 @@ router.post('/:id/revisions', (req, res, next) => { // Recalcule duree_mois si la date cible change réellement, pour que l'échéancier régénéré // atteigne effectivement cette nouvelle date (cf. convention date_cible = date_premiere_echeance // + (duree_mois - 1) mois, utilisée côté frontend pour tous les types de prêt). + const dateChangee = nouvelle_date_cible !== ancienne_date_cible; let nouveau_duree_mois = ancien_duree_mois; - if (nouvelle_date_cible !== ancienne_date_cible && inv.date_premiere_echeance) { + if (dateChangee && inv.date_premiere_echeance) { nouveau_duree_mois = monthsDiff(inv.date_premiere_echeance, nouvelle_date_cible) + 1; } + // Statut "Prolongation" : uniquement quand la date cible change réellement ET que le + // dossier est actuellement en_retard/procedure (renégociation d'un prêt à risque). Un + // changement de taux seul, ou une révision sur un dossier en_cours, ne déclenche jamais + // ce statut — décision confirmée avec Olivier le 22/08/26. Le dossier sort ainsi + // temporairement du scope "à risque" jusqu'à ce que jobs/autoStatut.js le repasse en + // en_retard si la nouvelle date cible est à son tour dépassée sans remboursement. + const RISQUE_STATUTS = ['en_retard', 'procedure']; + const ancien_statut = (dateChangee && RISQUE_STATUTS.includes(inv.statut)) ? inv.statut : null; + const nouveau_statut = ancien_statut ? 'prolongation' : null; + let revisionId; const tx = db.transaction(() => { const r = db.prepare(` INSERT INTO investissement_revisions (investissement_id, date_effet, ancien_taux, nouveau_taux, ancienne_date_cible, nouvelle_date_cible, - ancien_duree_mois, nouveau_duree_mois, motif) - VALUES (?,?,?,?,?,?,?,?,?) + ancien_duree_mois, nouveau_duree_mois, ancien_statut, nouveau_statut, motif) + VALUES (?,?,?,?,?,?,?,?,?,?,?) `).run(invId, body.date_effet, ancien_taux, nouveau_taux, ancienne_date_cible, nouvelle_date_cible, - ancien_duree_mois, nouveau_duree_mois, body.motif); + ancien_duree_mois, nouveau_duree_mois, ancien_statut, nouveau_statut, body.motif); revisionId = r.lastInsertRowid; + // COALESCE(?, statut) : nouveau_statut est NULL quand la révision ne doit pas changer + // le statut (taux seul, dossier déjà sain…) — le statut courant est alors préservé. db.prepare(` UPDATE investissements - SET taux_interet = ?, date_cible = ?, duree_mois = ?, date_debut_simul = ?, updated_at = datetime('now') + SET taux_interet = ?, date_cible = ?, duree_mois = ?, date_debut_simul = ?, + statut = COALESCE(?, statut), updated_at = datetime('now') WHERE id = ? - `).run(nouveau_taux, nouvelle_date_cible, nouveau_duree_mois, body.date_effet, invId); + `).run(nouveau_taux, nouvelle_date_cible, nouveau_duree_mois, body.date_effet, nouveau_statut, invId); regenererEcheancier(invId); }); @@ -670,7 +726,7 @@ router.delete('/:id/revisions/:rid', (req, res, next) => { try { const invId = Number(req.params.id); const inv = db.prepare(` - SELECT i.id FROM investissements i + SELECT i.id, i.statut FROM investissements i JOIN investisseurs inv ON inv.id = i.investisseur_id WHERE i.id = ? AND inv.user_id = ? `).get(invId, req.user.id); @@ -683,17 +739,25 @@ router.delete('/:id/revisions/:rid', (req, res, next) => { throw new HttpError(400, 'Seule la révision la plus récente peut être supprimée'); } + // Ne restaure le statut que si rien ne l'a fait évoluer depuis cette révision (ex : le + // job autoStatut a déjà repassé le dossier en_retard entre-temps) — sinon on écraserait + // un état plus récent et légitime avec l'ancien statut de la révision annulée. + const statutRestore = (derniere.nouveau_statut && inv.statut === derniere.nouveau_statut) + ? derniere.ancien_statut + : null; + const tx = db.transaction(() => { db.prepare(` UPDATE investissements SET taux_interet = ?, date_cible = ?, duree_mois = ?, + statut = COALESCE(?, statut), date_debut_simul = ( SELECT date_effet FROM investissement_revisions WHERE investissement_id = ? AND id != ? ORDER BY id DESC LIMIT 1 ), updated_at = datetime('now') WHERE id = ? - `).run(derniere.ancien_taux, derniere.ancienne_date_cible, derniere.ancien_duree_mois, invId, derniere.id, invId); + `).run(derniere.ancien_taux, derniere.ancienne_date_cible, derniere.ancien_duree_mois, statutRestore, invId, derniere.id, invId); db.prepare('DELETE FROM investissement_revisions WHERE id = ?').run(derniere.id); @@ -705,6 +769,163 @@ router.delete('/:id/revisions/:rid', (req, res, next) => { } catch (e) { next(e); } }); +// PUT /api/investissements/:id/revisions/:rid +// Corrige la révision la plus récente en place (même ligne d'historique conservée), plutôt que +// de la supprimer puis d'en recréer une — évite un aller-retour et garde une trace stable dans +// l'historique. ancien_taux/ancienne_date_cible/ancien_duree_mois restent figés sur l'état +// D'AVANT CETTE RÉVISION (jamais modifiés par une correction) ; seuls les nouveau_* et le motif +// sont mis à jour. Même contrainte que la suppression : seule la révision la plus récente peut +// être corrigée, pour éviter un état incohérent si une révision intermédiaire était éditée. +router.put('/:id/revisions/:rid', (req, res, next) => { + try { + const body = RevisionSchema.parse(req.body); + const invId = Number(req.params.id); + const inv = db.prepare(` + SELECT i.id, i.statut, i.date_premiere_echeance FROM investissements i + JOIN investisseurs inv ON inv.id = i.investisseur_id + WHERE i.id = ? AND inv.user_id = ? + `).get(invId, req.user.id); + if (!inv) throw new HttpError(404, 'Investissement introuvable'); + + const derniere = db.prepare( + 'SELECT * FROM investissement_revisions WHERE investissement_id = ? ORDER BY id DESC LIMIT 1' + ).get(invId); + if (!derniere || derniere.id !== Number(req.params.rid)) { + throw new HttpError(400, 'Seule la révision la plus récente peut être corrigée'); + } + + // Baseline inchangée : l'état d'AVANT cette révision (jamais retouché par une correction). + const ancien_taux = derniere.ancien_taux; + const ancienne_date_cible = derniere.ancienne_date_cible; + const ancien_duree_mois = derniere.ancien_duree_mois; + const nouveau_taux = body.nouveau_taux ?? ancien_taux; + const nouvelle_date_cible = body.nouvelle_date_cible ?? ancienne_date_cible; + + const dateChangee = nouvelle_date_cible !== ancienne_date_cible; + let nouveau_duree_mois = ancien_duree_mois; + if (dateChangee && inv.date_premiere_echeance) { + nouveau_duree_mois = monthsDiff(inv.date_premiere_echeance, nouvelle_date_cible) + 1; + } + + // Statut : on rejoue le même raisonnement que POST/DELETE combinés — d'abord retrouver le + // statut d'avant l'effet de cette révision (sauf si autre chose l'a fait évoluer entre + // temps, ex. autoStatut), puis réévaluer si la version CORRIGÉE doit déclencher Prolongation. + const RISQUE_STATUTS = ['en_retard', 'procedure']; + const statutBase = (derniere.nouveau_statut && inv.statut === derniere.nouveau_statut) + ? derniere.ancien_statut + : inv.statut; + const ancien_statut = (dateChangee && RISQUE_STATUTS.includes(statutBase)) ? statutBase : null; + const nouveau_statut = ancien_statut ? 'prolongation' : null; + const statutFinal = nouveau_statut ?? statutBase; + + const tx = db.transaction(() => { + db.prepare(` + UPDATE investissement_revisions + SET date_effet = ?, nouveau_taux = ?, nouvelle_date_cible = ?, nouveau_duree_mois = ?, + ancien_statut = ?, nouveau_statut = ?, motif = ? + WHERE id = ? + `).run(body.date_effet, nouveau_taux, nouvelle_date_cible, nouveau_duree_mois, + ancien_statut, nouveau_statut, body.motif, derniere.id); + + db.prepare(` + UPDATE investissements + SET taux_interet = ?, date_cible = ?, duree_mois = ?, date_debut_simul = ?, + statut = ?, updated_at = datetime('now') + WHERE id = ? + `).run(nouveau_taux, nouvelle_date_cible, nouveau_duree_mois, body.date_effet, statutFinal, invId); + + regenererEcheancier(invId); + }); + tx(); + + const revision = db.prepare('SELECT * FROM investissement_revisions WHERE id = ?').get(derniere.id); + res.json(revision); + } catch (e) { next(e); } +}); + +// POST /api/investissements/:id/perte-definitive +// Déclare qu'un investissement en_retard/procedure est définitivement perdu (irrécouvrable). +// Le montant est renseigné explicitement (perte totale ou partielle si récupération partielle) +// plutôt que recalculé côté serveur, pour laisser l'utilisateur ajuster au moment du constat. +// Trace l'ancien statut dans investissement_pertes pour permettre une annulation propre. +router.post('/:id/perte-definitive', (req, res, next) => { + try { + const body = PerteSchema.parse(req.body); + const invId = Number(req.params.id); + const inv = db.prepare(` + SELECT i.* FROM investissements i + JOIN investisseurs inv ON inv.id = i.investisseur_id + WHERE i.id = ? AND inv.user_id = ? + `).get(invId, req.user.id); + if (!inv) throw new HttpError(404, 'Investissement introuvable'); + if (!['en_retard', 'procedure'].includes(inv.statut)) { + throw new HttpError(400, 'Seuls les dossiers "En retard" ou "Procédure" peuvent être déclarés en perte définitive'); + } + + let perteId; + const tx = db.transaction(() => { + const r = db.prepare(` + INSERT INTO investissement_pertes (investissement_id, date_effet, montant_perte, ancien_statut, motif) + VALUES (?,?,?,?,?) + `).run(invId, body.date_effet, body.montant_perte, inv.statut, body.motif); + perteId = r.lastInsertRowid; + + db.prepare(` + UPDATE investissements SET statut = 'perte_definitive', updated_at = datetime('now') WHERE id = ? + `).run(invId); + + recordHistory(invId, { + type_evenement: 'perte_definitive', + changements: [{ champ: 'statut', label: 'Statut', ancienne_valeur: inv.statut, nouvelle_valeur: 'perte_definitive' }], + notes: `Perte définitive déclarée (${body.montant_perte.toFixed(2)} €) : ${body.motif}`, + }); + }); + tx(); + + const perte = db.prepare('SELECT * FROM investissement_pertes WHERE id = ?').get(perteId); + res.status(201).json(perte); + } catch (e) { next(e); } +}); + +// DELETE /api/investissements/:id/perte-definitive +// Annule la déclaration de perte la plus récente et restaure le statut précédent +// (en_retard ou procédure) — le dossier redevient actif au sens fiscal/KPI. +router.delete('/:id/perte-definitive', (req, res, next) => { + try { + const invId = Number(req.params.id); + const inv = db.prepare(` + SELECT i.* FROM investissements i + JOIN investisseurs inv ON inv.id = i.investisseur_id + WHERE i.id = ? AND inv.user_id = ? + `).get(invId, req.user.id); + if (!inv) throw new HttpError(404, 'Investissement introuvable'); + if (inv.statut !== 'perte_definitive') { + throw new HttpError(400, "Ce dossier n'est pas déclaré en perte définitive"); + } + + const derniere = db.prepare( + 'SELECT * FROM investissement_pertes WHERE investissement_id = ? ORDER BY id DESC LIMIT 1' + ).get(invId); + const statutRestaure = derniere?.ancien_statut || 'en_retard'; + + const tx = db.transaction(() => { + if (derniere) db.prepare('DELETE FROM investissement_pertes WHERE id = ?').run(derniere.id); + db.prepare(` + UPDATE investissements SET statut = ?, updated_at = datetime('now') WHERE id = ? + `).run(statutRestaure, invId); + + recordHistory(invId, { + type_evenement: 'annulation_perte_definitive', + changements: [{ champ: 'statut', label: 'Statut', ancienne_valeur: 'perte_definitive', nouvelle_valeur: statutRestaure }], + notes: 'Annulation de la déclaration de perte définitive', + }); + }); + tx(); + + res.status(204).end(); + } catch (e) { next(e); } +}); + // PUT /api/investissements/:id/auto-reinvest { active: true|false } router.put('/:id/auto-reinvest', (req, res, next) => { try { diff --git a/backend/src/routes/simul.js b/backend/src/routes/simul.js index 66231e9..06978c1 100644 --- a/backend/src/routes/simul.js +++ b/backend/src/routes/simul.js @@ -3,7 +3,7 @@ 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'; +import { buildSchedule, adjustSimulForActuals, replayAdjustSimulForActuals } from '../utils/schedule.js'; const router = Router(); @@ -149,7 +149,14 @@ router.post('/recalculate', (req, res, next) => { const { investissement_id } = req.body; if (!investissement_id) throw new HttpError(400, 'investissement_id required'); assertOwnedInvestissement(Number(investissement_id), req.user.id); + // adjustSimulForActuals() gère aussi la régénération complète quand aucun capital n'a + // encore été remboursé ; replayAdjustSimulForActuals() rejoue ensuite tout l'historique des + // remboursements de capital anticipés pour reconstruire fidèlement les valeurs + // intermédiaires qu'un simple appel unique ne peut pas restaurer (cf. bug "L'Olympique" du + // 22/08/26, feedback_generateSimul_restructuration_dates.md) — sans effet si l'échéancier + // était déjà cohérent. adjustSimulForActuals(db, Number(investissement_id)); + replayAdjustSimulForActuals(db, Number(investissement_id)); const rows = db.prepare( 'SELECT * FROM simul_remboursements WHERE investissement_id=? ORDER BY numero_echeance' ).all(investissement_id); diff --git a/backend/src/routes/taxreport.js b/backend/src/routes/taxreport.js index 3c1d7dc..95255e3 100644 --- a/backend/src/routes/taxreport.js +++ b/backend/src/routes/taxreport.js @@ -59,19 +59,21 @@ router.get('/', (req, res) => { recap.net_recu += recapInteretsPlateforme.net_recu; recap.nb_remboursements += recapInteretsPlateforme.nb_remboursements; + // Pertes en capital : uniquement les dossiers explicitement déclarés en perte + // définitive (statut='perte_definitive'), avec le montant réellement acté dans + // investissement_pertes — pas les simples retards, ni les clôtures normales. 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 + ip.montant_perte AS perte_capital, + i.statut, ip.date_effet FROM investissements i JOIN plateformes p ON p.id = i.plateforme_id + JOIN investissement_pertes ip ON ip.investissement_id = i.id WHERE ${invCond} - AND i.statut IN ('en_retard','cloture') - AND substr(i.updated_at,1,4) = ? + AND i.statut = 'perte_definitive' + AND substr(ip.date_effet,1,4) = ? `).all(invArg, annee); const pertesTotales = pertes.reduce((s, p) => s + Math.max(0, p.perte_capital), 0); @@ -270,20 +272,19 @@ function buildCerfa2561(annee, invCond, invCondBonus2561, invArg) { const rowsMerged = Object.values(rowsMap).sort((a, b) => a.plateforme_nom.localeCompare(b.plateforme_nom) || (a.investisseur_nom ?? '').localeCompare(b.investisseur_nom ?? '')); - // Pertes en capital par plateforme × investisseur + // Pertes en capital par plateforme × investisseur — uniquement les dossiers + // explicitement déclarés en perte définitive (voir note plus haut dans la route '/'). 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 + COALESCE(SUM(ip.montant_perte), 0) AS perte_capital FROM investissements i JOIN plateformes p ON p.id = i.plateforme_id + JOIN investissement_pertes ip ON ip.investissement_id = i.id WHERE ${invCond} - AND i.statut IN ('en_retard','cloture') - AND substr(i.updated_at,1,4) = ? + AND i.statut = 'perte_definitive' + AND substr(ip.date_effet,1,4) = ? GROUP BY p.id, i.investisseur_id `).all(invArg, annee); diff --git a/backend/src/routes/v1/investissements.js b/backend/src/routes/v1/investissements.js index bf190bf..9f4315d 100644 --- a/backend/src/routes/v1/investissements.js +++ b/backend/src/routes/v1/investissements.js @@ -21,7 +21,7 @@ const LIST_COLUMNS = ` * parameters: * - in: query * name: statut - * schema: { type: string, enum: [en_cours, rembourse, en_retard, procedure, cloture] } + * schema: { type: string, enum: [en_cours, rembourse, en_retard, procedure, cloture, perte_definitive, prolongation] } * responses: * 200: { description: Liste des investissements } */ diff --git a/backend/src/routes/v1/taxreport.js b/backend/src/routes/v1/taxreport.js index 5375b6e..c62913d 100644 --- a/backend/src/routes/v1/taxreport.js +++ b/backend/src/routes/v1/taxreport.js @@ -88,13 +88,17 @@ router.get('/', (req, res) => { // ── Synthèse 2042 — mêmes cases et mêmes formules que le pavé "Cases fiscales 2042 — // synthèse" en haut de la page Fiscalité (TaxReport.jsx, fonction load()) : - // - 2TT / 2TY : cumul direct des lignes 2561 françaises. + // - 2TT : cumul direct des lignes 2561 françaises. 2TY (pertes en capital, + // statut 'perte_definitive' uniquement) : cumul sur toutes les lignes 2561, + // FR et étranger confondues — une perte définitive n'est pas spécifique à la France. // - 2TR : cumul des lignes 2561 françaises + brut étranger (toujours reporté, que le // PFO s'applique ou non — seule la déclaration annuelle 2047 est en jeu ici). // - 2BH / 2CK : la part étrangère n'est ajoutée que si pfo_assujetti est vrai, sinon // ces cases ne sont pas censées être alimentées (cf. mémoire projet — la case 2CK // ne s'alimente que si la déclaration 2778-SD est active). const frLignes = cerfa2561.lignes.filter(l => l.domiciliation === 'FR'); + // case_2TY (pertes en capital) n'est pas spécifique FR/étranger — voir TaxReport.jsx + const allLignes = cerfa2561.lignes; const platEtr = cerfa2778_mensuel?.plateformes ?? []; const etrBA = p => Object.values(p.mois ?? {}).reduce((s, v) => s + v, 0); const totalBA = platEtr.reduce((s, p) => s + etrBA(p), 0); @@ -105,7 +109,7 @@ router.get('/', (req, res) => { case_2TR: sumBy(frLignes, 'case_2TR') + Math.round(totalBA), case_2BH: sumBy(frLignes, 'case_2BH') + (pfoAssujetti ? Math.round(totalBA) : 0), case_2CK: sumBy(frLignes, 'case_2CK') + (pfoAssujetti ? Math.round(totalBA * PFO_RATE) : 0), - case_2TY: sumBy(frLignes, 'case_2TY'), + case_2TY: sumBy(allLignes, 'case_2TY'), }; res.json({ diff --git a/backend/src/utils/schedule.js b/backend/src/utils/schedule.js index 49936ff..8bd9aca 100644 --- a/backend/src/utils/schedule.js +++ b/backend/src/utils/schedule.js @@ -158,7 +158,78 @@ export function adjustSimulForActuals(db, investissementId) { '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 + applyCapitalAdjustment(db, inv, reinvests, last_date, total_capital); + + // Ajustement de la première période partielle (mois incomplet) + adjustFirstPartialPeriod(db, investissementId); +} + +/** + * Rejoue chronologiquement l'intégralité de l'historique des remboursements de capital + * anticipés pour reconstruire l'ajustement progressif de l'échéancier. + * + * Nécessaire après une régénération COMPLÈTE (generateSimul()/generateSimulWithReinvestissements() + * en mode standard, qui réécrit TOUT l'échéancier à neuf à partir du montant_investi d'origine, + * sans connaître les remboursements anticipés déjà effectués) — contrairement à un simple appel + * à adjustSimulForActuals(), qui ne recalcule que les échéances postérieures au DERNIER + * remboursement connu et ne peut donc pas reconstituer une valeur intermédiaire déjà "gelée" + * par un remboursement plus récent (ex. la dernière échéance d'un prêt in fine, soldée + * partiellement : son capital_prevu ajusté a été fixé par le remboursement anticipé PRÉCÉDENT, + * pas par celui qui la solde, donc un unique appel après régénération ne la retoucherait + * jamais — il faut rejouer les remboursements dans l'ordre pour reconstruire cette valeur). + * + * Utilisé par regenererEcheancier() (routes/investissements.js) après une régénération + * complète (ex. annulation d'une révision) — jamais après une restructuration partielle, où + * les échéances déjà honorées sont préservées telles quelles par generateSimul() lui-même. + * + * Bug découvert le 22/08/26 sur le dossier "L'Olympique" : l'annulation d'une révision a + * effacé tout l'historique d'ajustement de capital anticipé accumulé sur 18 échéances, + * l'échéancier retombant sur les valeurs naïves d'origine (intérêts et capital final + * recalculés sur le montant investi total, comme si aucun remboursement anticipé n'avait + * jamais eu lieu). + */ +export function replayAdjustSimulForActuals(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 == null || !inv.duree_mois) return; + + const reinvests = db.prepare( + 'SELECT montant, date_reinvestissement FROM reinvestissements WHERE investissement_id = ? ORDER BY date_reinvestissement' + ).all(investissementId); + + // Chaque date distincte où du capital a été remboursé, dans l'ordre chronologique — ce sont + // les points où adjustSimulForActuals() aurait été appelé en conditions réelles. + const dates = db.prepare(` + SELECT DISTINCT date_remb FROM remboursements + WHERE investissement_id = ? AND capital > 0 + ORDER BY date_remb ASC + `).all(investissementId).map(r => r.date_remb); + + if (!dates.length) return; // aucun remboursement de capital : l'échéancier naïf est déjà correct + + for (const d of dates) { + const { total_capital } = db.prepare( + 'SELECT COALESCE(SUM(capital), 0) AS total_capital FROM remboursements WHERE investissement_id = ? AND date_remb <= ?' + ).get(investissementId, d); + applyCapitalAdjustment(db, inv, reinvests, d, total_capital); + } + + adjustFirstPartialPeriod(db, investissementId); +} + +/** + * Cœur du recalcul des échéances futures pour un capital remboursé et une date de référence + * donnés — factorisé entre adjustSimulForActuals() (un seul point dans le temps : le dernier + * remboursement connu) et replayAdjustSimulForActuals() (rejoue ce même calcul à CHAQUE point + * historique, dans l'ordre, pour reconstruire les valeurs intermédiaires "gelées" par des + * remboursements plus récents). + */ +function applyCapitalAdjustment(db, inv, reinvests, last_date, total_capital) { + // Capital effectivement investi jusqu'à la date de référence // (initial + réinvestissements survenus avant ou à cette date) const capitalAtLastDate = round2( inv.montant_investi + @@ -167,25 +238,25 @@ export function adjustSimulForActuals(db, investissementId) { const remainingCapital = round2(capitalAtLastDate - total_capital); - // Entrées de simulation à recalculer (strictement après la date du dernier remb capital) + // Entrées de simulation à recalculer (strictement après la date de référence) const futureEntries = db.prepare(` SELECT * FROM simul_remboursements WHERE investissement_id = ? AND date_prevue > ? ORDER BY numero_echeance - `).all(investissementId, last_date); + `).all(inv.id, 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 + // Entrée simul qui couvre la période de la date de référence 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); + `).get(inv.id, last_date); db.transaction(() => { // Supprimer les échéances futures devenues caduques @@ -213,7 +284,7 @@ export function adjustSimulForActuals(db, investissementId) { const nFuture = futureEntries.length; const type = inv.type_remb || 'in_fine'; - // Réinvestissements encore à venir (après la date du dernier remboursement) + // Réinvestissements encore à venir (après la date de référence) const futureReinvests = reinvests.filter(r => r.date_reinvestissement > last_date); const updates = []; @@ -278,9 +349,6 @@ export function adjustSimulForActuals(db, investissementId) { `); 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); } /** @@ -384,20 +452,49 @@ export function generateSimulWithReinvestissements(db, investissementId) { // taux_interet peut légitimement valoir 0 (cf. commentaire dans adjustSimulForActuals) if (inv.taux_interet == null || !inv.duree_mois) return; - const startDate = inv.date_debut_simul || inv.date_premiere_echeance || inv.date_souscription; - if (!startDate) return; - 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; + const isRestructuration = !!inv.date_debut_simul; - // 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); + // En mode restructuration, il faut connaître les échéances déjà honorées AVANT de + // calculer la date de départ de la suite de l'échéancier (cf. generateSimul() pour le + // détail du raisonnement) — simple lecture, peut se faire hors transaction. + let keptEntries = []; + if (isRestructuration) { + keptEntries = db.prepare(` + SELECT sr.id, sr.numero_echeance FROM simul_remboursements sr + WHERE sr.investissement_id = ? + AND sr.date_prevue < ? + AND EXISTS ( + SELECT 1 FROM remboursements r + WHERE r.investissement_id = sr.investissement_id + AND substr(r.date_remb, 1, 7) = substr(sr.date_prevue, 1, 7) + ) + ORDER BY sr.numero_echeance + `).all(investissementId, inv.date_debut_simul); + } + + let startDate, effectiveDuree = inv.duree_mois, elapsedMonths = 0; + + if (isRestructuration) { + // Même logique que generateSimul() : numérotation absolue anti-collision (jamais en + // dessous du plus haut numero_echeance déjà conservé) et date de départ qui continue + // la cadence mensuelle d'origine plutôt que de repartir littéralement de date_debut_simul. + const dateBasedElapsed = (inv.date_premiere_echeance && inv.date_debut_simul > inv.date_premiere_echeance) + ? monthsDiff(inv.date_premiere_echeance, inv.date_debut_simul) + : keptEntries.length; + const maxKeptNumero = keptEntries.reduce((max, e) => Math.max(max, e.numero_echeance), 0); + elapsedMonths = Math.max(dateBasedElapsed, maxKeptNumero); + effectiveDuree = Math.max(1, inv.duree_mois - elapsedMonths); + + const cadenceBase = inv.date_premiere_echeance || inv.date_debut_simul; + startDate = finDeMois ? addMonthsEOM(cadenceBase, elapsedMonths) : addMonths(cadenceBase, elapsedMonths); + } else { + startDate = inv.date_premiere_echeance || inv.date_souscription; + if (!startDate) return; } // Calendrier de base pour obtenir les dates de chaque échéance @@ -463,24 +560,10 @@ export function generateSimulWithReinvestissements(db, investissementId) { } db.transaction(() => { - if (inv.date_debut_simul) { + if (isRestructuration) { // ── Mode restructuration ────────────────────────────────────────────── - // Même logique que generateSimul() : conserver les échéances déjà payées avant - // la date de restructuration, supprimer le reste, et renuméroter à partir du - // nombre de mois réellement écoulés (pas de repartir à 1, qui ferait disparaître - // les échéances passées — déjà payées — de la table des projections). - const keptEntries = db.prepare(` - SELECT sr.id, sr.numero_echeance FROM simul_remboursements sr - WHERE sr.investissement_id = ? - AND sr.date_prevue < ? - AND EXISTS ( - SELECT 1 FROM remboursements r - WHERE r.investissement_id = sr.investissement_id - AND substr(r.date_remb, 1, 7) = substr(sr.date_prevue, 1, 7) - ) - ORDER BY sr.numero_echeance - `).all(investissementId, inv.date_debut_simul); - + // keptEntries/elapsedMonths déjà calculés plus haut (nécessaire pour la date de + // départ du calendrier de base) : on les réutilise tels quels ici. if (keptEntries.length > 0) { db.prepare( `DELETE FROM simul_remboursements WHERE investissement_id = ? AND id NOT IN (${keptEntries.map(() => '?').join(',')})` @@ -489,10 +572,6 @@ export function generateSimulWithReinvestissements(db, investissementId) { db.prepare('DELETE FROM simul_remboursements WHERE investissement_id=?').run(investissementId); } - const elapsedMonths = (inv.date_premiere_echeance && inv.date_debut_simul > inv.date_premiere_echeance) - ? monthsDiff(inv.date_premiere_echeance, inv.date_debut_simul) - : keptEntries.length; - const stmt = db.prepare(` INSERT INTO simul_remboursements (investissement_id, numero_echeance, date_prevue, capital_prevu, interets_prevus, total_prevu) @@ -520,9 +599,13 @@ export function generateSimulWithReinvestissements(db, investissementId) { * 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. + * Si date_debut_simul est renseigné (restructuration de prêt suite à une révision des + * conditions), 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 — et la suite + * de l'échéancier continue la cadence mensuelle d'origine (même jour du mois que + * date_premiere_echeance) à partir de la première échéance pas encore écoulée. date_debut_simul + * n'est PAS utilisé comme date de la première nouvelle échéance : ce n'est que la date + * d'effet administrative de la révision, qui peut tomber n'importe quel jour du mois. */ export function generateSimul(db, inv) { const { id, montant_investi, taux_interet, duree_mois, type_remb, freq_interets, @@ -531,16 +614,66 @@ export function generateSimul(db, inv) { // taux_interet peut légitimement valoir 0 (cf. commentaire dans adjustSimulForActuals) if (taux_interet == null || !duree_mois) return; - // date_debut_simul remplace le point de départ quand le prêt a été restructuré - const startDate = date_debut_simul || date_premiere_echeance || date_souscription; - if (!startDate) return; + const finDeMois = !!echeance_fin_de_mois; + const isRestructuration = !!date_debut_simul; - // 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); + // En mode restructuration, on a besoin de connaître les échéances déjà honorées + // AVANT de calculer la date de départ de la suite de l'échéancier (cf. plus bas) — + // simple lecture, sans effet de bord, peut se faire hors transaction. + let keptEntries = []; + if (isRestructuration) { + // 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) seront supprimées avec tout ce qui suit. + 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); + } + + let startDate, effectiveDuree = duree_mois, elapsedMonths = 0; + + if (isRestructuration) { + // 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 dateBasedElapsed = (date_premiere_echeance && date_debut_simul > date_premiere_echeance) + ? monthsDiff(date_premiere_echeance, date_debut_simul) + : keptEntries.length; // fallback : nombre de lignes conservées + + // Garde-fou anti-collision : le calcul calendaire ci-dessus peut sous-estimer le nombre + // d'échéances déjà écoulées (ex. date_debut_simul tombe le même mois que la dernière + // échéance déjà honorée, avant que le mois suivant ne soit atteint), auquel cas la + // numérotation reprendrait à un numero_echeance déjà utilisé par une échéance conservée + // ci-dessus, provoquant une violation de la contrainte UNIQUE(investissement_id, + // numero_echeance) à l'INSERT. On ne redescend donc jamais en dessous du plus haut + // numero_echeance déjà conservé. + const maxKeptNumero = keptEntries.reduce((max, e) => Math.max(max, e.numero_echeance), 0); + elapsedMonths = Math.max(dateBasedElapsed, maxKeptNumero); + effectiveDuree = Math.max(1, duree_mois - elapsedMonths); + + // Date de départ de la suite de l'échéancier : on continue la cadence mensuelle + // d'origine (même jour du mois que date_premiere_echeance) à partir de la première + // échéance pas encore écoulée — on ne repart PAS littéralement de date_debut_simul, + // qui n'est que la date d'effet administrative de la révision (ex. le jour où + // l'utilisateur l'a saisie) et peut tomber le même mois qu'une échéance déjà honorée, + // ce qui créerait deux échéances dans le même mois (cf. bug signalé par Olivier le + // 22/08/26 sur le dossier "L'Olympique" : échéances 19 et 20 incohérentes). + const cadenceBase = date_premiere_echeance || date_debut_simul; + startDate = finDeMois ? addMonthsEOM(cadenceBase, elapsedMonths) : addMonths(cadenceBase, elapsedMonths); + } else { + startDate = date_premiere_echeance || date_souscription; + if (!startDate) return; } const echeances = buildSchedule({ @@ -550,28 +683,11 @@ export function generateSimul(db, inv) { type: type_remb || 'in_fine', freq: freq_interets || 'mensuel', startDate, - finDeMois: !!echeance_fin_de_mois, + finDeMois, }); 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 (isRestructuration) { if (keptEntries.length > 0) { // Supprime tout sauf les entrées payées conservées db.prepare( @@ -582,15 +698,6 @@ export function generateSimul(db, inv) { 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) diff --git a/frontend/src/components/CapitalMensuelTable.jsx b/frontend/src/components/CapitalMensuelTable.jsx index 0d155bb..6cebf9d 100644 --- a/frontend/src/components/CapitalMensuelTable.jsx +++ b/frontend/src/components/CapitalMensuelTable.jsx @@ -89,18 +89,27 @@ export default function CapitalMensuelTable({ allRows, allRembs, allReinvests, p const { grid, multiDetenteur } = useMemo(() => { if (!allRows.length) return { grid: null, multiDetenteur: false }; - const ACTIVE = ['en_cours', 'en_retard', 'procedure']; + // prolongation compte comme actif au même titre que en_retard/procedure dont il + // découle (renégociation) — aucune déduction de capital associée, contrairement à + // perte_definitive ci-dessous. + const ACTIVE = ['en_cours', 'en_retard', 'procedure', 'prolongation']; // Index plateformes par id (pour nom + detenteur) const platMap = {}; for (const p of plats) platMap[p.id] = p; - // Pour chaque investissement, capital encours au end of month M + // Pour chaque investissement, capital encours au end of month M. + // Un investissement en perte_definitive reste compté normalement pour les + // mois antérieurs à la date d'effet de la perte (fidélité historique), puis + // voit son capital réduit du montant perdu (capital_perdu) à partir de ce + // mois — sinon il disparaîtrait du total de la plateforme dès la + // déclaration, faisant perdre le capital restant dû (perte partielle). const getCapitalAtEndOfMonth = (inv, Y, M) => { const endM = endOfMonth(Y, M); if (inv.date_souscription > endM) return 0; const startM = startOfMonth(Y, M); - const isActive = ACTIVE.includes(inv.statut) || + const isPerteDef = inv.statut === 'perte_definitive'; + const isActive = ACTIVE.includes(inv.statut) || isPerteDef || ((inv.date_cible || lastRembDateMap[inv.id] || null) >= startM); if (!isActive) return 0; @@ -112,7 +121,11 @@ export default function CapitalMensuelTable({ allRows, allRembs, allReinvests, p .filter(rb => rb.date && rb.date <= endM) .reduce((s, rb) => s + rb.capital, 0); - return Math.max(0, inv.montant_investi + reinvM - capRembM); + let capital = Math.max(0, inv.montant_investi + reinvM - capRembM); + if (isPerteDef && inv.perte_date_effet && endM >= inv.perte_date_effet) { + capital = Math.max(0, capital - (inv.capital_perdu || 0)); + } + return capital; }; // Agréger par plateforme (id) diff --git a/frontend/src/components/InvMensuelTable.jsx b/frontend/src/components/InvMensuelTable.jsx index 6ac7f59..35fd9b4 100644 --- a/frontend/src/components/InvMensuelTable.jsx +++ b/frontend/src/components/InvMensuelTable.jsx @@ -53,13 +53,22 @@ export default function InvMensuelTable({ rows, allRembs, allReinvests, year, or return map; }, [allRembs]); - /* ── Capital encours d'un investissement à fin de mois M ── */ + /* ── Capital encours d'un investissement à fin de mois M ── + * Un investissement en perte_definitive reste compté normalement pour les + * mois antérieurs à la date d'effet de la perte (fidélité historique), puis + * voit son capital réduit du montant perdu (capital_perdu) à partir de ce + * mois — au lieu de disparaître de la table, ce qui ferait perdre tout le + * capital restant dû (potentiellement une perte partielle) des mois suivants. */ const getCapital = (inv, Y, M) => { const endM = endOfMonth(Y, M); if (inv.date_souscription > endM) return 0; const startM = startOfMonth(Y, M); - const ACTIVE = ['en_cours', 'en_retard', 'procedure']; - const isActive = ACTIVE.includes(inv.statut) || + // prolongation compte comme actif au même titre que en_retard/procedure dont il + // découle (renégociation) — aucune déduction de capital associée, contrairement à + // perte_definitive ci-dessous. + const ACTIVE = ['en_cours', 'en_retard', 'procedure', 'prolongation']; + const isPerteDef = inv.statut === 'perte_definitive'; + const isActive = ACTIVE.includes(inv.statut) || isPerteDef || ((inv.date_cible || lastRembDateMap[inv.id] || null) >= startM); if (!isActive) return 0; @@ -69,7 +78,11 @@ export default function InvMensuelTable({ rows, allRembs, allReinvests, year, or const capRembM = (capRembByInv[inv.id] || []) .filter(rb => rb.date && rb.date <= endM) .reduce((s, rb) => s + rb.capital, 0); - return Math.max(0, inv.montant_investi + reinvM - capRembM); + let capital = Math.max(0, inv.montant_investi + reinvM - capRembM); + if (isPerteDef && inv.perte_date_effet && endM >= inv.perte_date_effet) { + capital = Math.max(0, capital - (inv.capital_perdu || 0)); + } + return capital; }; /* ── Grille : une ligne par investissement ── */ diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index dd3f5da..4e5082b 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -156,7 +156,7 @@ function TrendBadge({ current, prev, invert = false }) { } /* ── Carte KPI individuelle ── */ -function KpiCard({ title, value, badge, refValue, onClick, hint }) { +function KpiCard({ title, value, badge, refValue, note, onClick, hint }) { return (
{refValue}
)} + {note && ( +
{note}
+ )} ); } /* ── KPI filtrés par année ── */ -function DashboardKpis({ portfolio, netMode, pfuRates, capitalMensuelData, activeId, activeView, soldePortefeuilleLive, correctionsTotal }) { +function DashboardKpis({ portfolio, netMode, pfuRates, capitalMensuelData, activeId, activeView, soldePortefeuilleLive, correctionsTotal, investissements }) { + // Montant total déjà déclaré en perte définitive (tous statuts confondus, + // seuls les dossiers passés en perte_definitive ont un capital_perdu > 0) — + // affiché en complément du KPI "Capital en risque", qui les exclut déjà + // (ce ne sont plus des dossiers "à risque" mais des pertes actées). + const capitalPertesDefinitives = (investissements || []).reduce((s, r) => s + (r.capital_perdu || 0), 0); + const { annee, modeGlobal, rawDataGlobal, rawData, currentYear, currentMonth, setInclureInterets, setInclureCapital, setInclureCashback, @@ -451,6 +460,9 @@ function DashboardKpis({ portfolio, netMode, pfuRates, capitalMensuelData, activ refValue={isCurrentYear && enDefautPrev != null && enDefautPrev > 0 ? fmtEUR(enDefautPrev) + ' en ' + prevMonthLabel : null} + note={capitalPertesDefinitives > 0 + ? `dont ${fmtEUR(capitalPertesDefinitives)} déjà déclarés en perte définitive (non compris ci-dessus)` + : null} /> diff --git a/frontend/src/pages/InvestissementDetail.jsx b/frontend/src/pages/InvestissementDetail.jsx index 05b1df1..17c3b2a 100644 --- a/frontend/src/pages/InvestissementDetail.jsx +++ b/frontend/src/pages/InvestissementDetail.jsx @@ -67,11 +67,13 @@ function typeLabel(type_remb, freq_interets) { } const STATUT_META = { - en_cours: { label: 'En cours', color: '#3b82f6', bg: 'rgba(59,130,246,0.12)' }, - rembourse: { label: 'Remboursé', color: '#22c55e', bg: 'rgba(34,197,94,0.12)' }, - en_retard: { label: 'En retard', color: '#ef4444', bg: 'rgba(239,68,68,0.12)' }, - procedure: { label: 'Procédure', color: '#f97316', bg: 'rgba(249,115,22,0.12)' }, - cloture: { label: 'Clôturé', color: '#6b7280', bg: 'rgba(107,114,128,0.12)' }, + en_cours: { label: 'En cours', color: '#3b82f6', bg: 'rgba(59,130,246,0.12)' }, + rembourse: { label: 'Remboursé', color: '#22c55e', bg: 'rgba(34,197,94,0.12)' }, + en_retard: { label: 'En retard', color: '#ef4444', bg: 'rgba(239,68,68,0.12)' }, + procedure: { label: 'Procédure', color: '#f97316', bg: 'rgba(249,115,22,0.12)' }, + cloture: { label: 'Clôturé', color: '#6b7280', bg: 'rgba(107,114,128,0.12)' }, + perte_definitive: { label: 'Perte définitive', color: '#374151', bg: 'rgba(55,65,81,0.12)' }, + prolongation: { label: 'Prolongation', color: '#8b5cf6', bg: 'rgba(139,92,246,0.12)' }, }; @@ -137,6 +139,8 @@ export default function InvestissementDetail() { const [confirmingHistDelete, setConfirmingHistDelete] = useState(null); // id de l'entrée à supprimer // Modal révision des conditions de prêt (taux/date suite à un événement) const [revisionModalOpen, setRevisionModalOpen] = useState(false); + // id de la révision en cours de correction (édition en place) ; null = création d'une nouvelle révision + const [revisionEditId, setRevisionEditId] = useState(null); const [revisionForm, setRevisionForm] = useState({ date_effet: today(), changeTaux: false, nouveau_taux: '', @@ -146,6 +150,12 @@ export default function InvestissementDetail() { const [revisionErr, setRevisionErr] = useState(null); const [revisionSaving, setRevisionSaving] = useState(false); const [confirmingRevisionDelete, setConfirmingRevisionDelete] = useState(null); // id de la révision à supprimer + // Modal déclaration de perte définitive (capital irrécouvrable, dossiers en_retard/procedure) + const [perteModalOpen, setPerteModalOpen] = useState(false); + const [perteForm, setPerteForm] = useState({ date_effet: today(), montant_perte: '', motif: '' }); + const [perteErr, setPerteErr] = useState(null); + const [perteSaving, setPerteSaving] = useState(false); + const [confirmingPerteCancel, setConfirmingPerteCancel] = useState(false); const [loading, setLoading] = useState(true); const [recalculating, setRecalculating] = useState(false); // Modal réinvestissement @@ -609,6 +619,7 @@ export default function InvestissementDetail() { }; const openRevisionModal = () => { + setRevisionEditId(null); setRevisionForm({ date_effet: today(), changeTaux: false, nouveau_taux: '', @@ -618,7 +629,22 @@ export default function InvestissementDetail() { setRevisionErr(null); setRevisionModalOpen(true); }; - const closeRevisionModal = () => { setRevisionModalOpen(false); setRevisionErr(null); }; + // Réouvre la modale pré-remplie sur la révision existante, pour la corriger en place + // (même ligne d'historique conservée) plutôt que de la supprimer puis la recréer. + const openEditRevisionModal = (rev) => { + setRevisionEditId(rev.id); + setRevisionForm({ + date_effet: rev.date_effet, + changeTaux: String(rev.ancien_taux) !== String(rev.nouveau_taux), + nouveau_taux: rev.nouveau_taux != null ? String(rev.nouveau_taux) : '', + changeDate: String(rev.ancienne_date_cible) !== String(rev.nouvelle_date_cible), + nouvelle_date_cible: rev.nouvelle_date_cible || '', + motif: rev.motif || '', + }); + setRevisionErr(null); + setRevisionModalOpen(true); + }; + const closeRevisionModal = () => { setRevisionModalOpen(false); setRevisionEditId(null); setRevisionErr(null); }; const submitRevision = async (e) => { e?.preventDefault?.(); @@ -639,13 +665,19 @@ export default function InvestissementDetail() { } setRevisionSaving(true); try { - await api.post(`/investissements/${id}/revisions`, { + const payload = { date_effet: revisionForm.date_effet, nouveau_taux: revisionForm.changeTaux ? Number(revisionForm.nouveau_taux) : undefined, nouvelle_date_cible: revisionForm.changeDate ? revisionForm.nouvelle_date_cible : undefined, motif: revisionForm.motif.trim(), - }); + }; + if (revisionEditId) { + await api.put(`/investissements/${id}/revisions/${revisionEditId}`, payload); + } else { + await api.post(`/investissements/${id}/revisions`, payload); + } setRevisionModalOpen(false); + setRevisionEditId(null); await load(); } catch (e) { // Surface le détail des erreurs de validation (Zod) plutôt que le message générique @@ -717,6 +749,7 @@ export default function InvestissementDetail() { const lastTauxRevision = [...revisions].reverse().find(r => String(r.ancien_taux) !== String(r.nouveau_taux)); const lastDateCibleRevision = [...revisions].reverse().find(r => String(r.ancienne_date_cible) !== String(r.nouvelle_date_cible)); const reinvs = inv.reinvestissements || []; + const pertes = inv.pertes || []; // Capital total = montant initial + réinvestissements const capitalTotal = inv.capital_total ?? inv.montant_investi; const autoReinvActive = !!inv?.auto_reinvest; @@ -724,7 +757,10 @@ export default function InvestissementDetail() { const multiDetenteur = new Set(plats.map(p => p.investisseur_id)).size > 1; // Calculés depuis les remboursements chargés (la route /:id ne retourne pas ces agrégats) const capitalRembourse = remb.reduce((s, r) => s + (r.capital || 0), 0); - const capRestant = Math.max(0, capitalTotal - capitalRembourse); + // Capital déclaré en perte définitive (0 si non déclaré) — déduit du capital restant dû + // pour que le prêt sorte réellement du capital actif une fois la perte actée. + const capitalPerdu = pertes.reduce((s, p) => s + (p.montant_perte || 0), 0); + const capRestant = Math.max(0, capitalTotal - capitalRembourse - capitalPerdu); const interetsPercus = remb.reduce((s, r) => s + (r.interets_bruts || 0), 0); const interetsPercusNet = remb.reduce((s, r) => s + (r.interets_nets || 0), 0); @@ -850,6 +886,13 @@ export default function InvestissementDetail() { motif: r.motif, created_at: r.created_at, })), + pertes: (inv.pertes || []).map(p => ({ + date_effet: p.date_effet, + montant_perte: p.montant_perte, + ancien_statut: p.ancien_statut, + motif: p.motif, + created_at: p.created_at, + })), }; const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); @@ -858,6 +901,50 @@ export default function InvestissementDetail() { URL.revokeObjectURL(url); }; + // ── Déclaration de perte définitive ────────────────────────────── + // Réservée aux dossiers en_retard/procedure (garde-fou côté backend aussi). + // Le montant est préfilli avec le capital restant dû mais reste éditable + // (perte partielle possible si une partie du capital a déjà été recouvrée). + const openPerteModal = () => { + setPerteForm({ date_effet: today(), montant_perte: String(Math.round(capRestant * 100) / 100), motif: '' }); + setPerteErr(null); + setPerteModalOpen(true); + }; + const closePerteModal = () => { setPerteModalOpen(false); setPerteErr(null); }; + + const submitPerte = async (e) => { + e?.preventDefault?.(); + setPerteErr(null); + if (!perteForm.date_effet) { setPerteErr("La date d'effet est requise."); return; } + if (perteForm.montant_perte === '' || Number(perteForm.montant_perte) < 0) { + setPerteErr('Le montant de la perte doit être un nombre positif ou nul.'); + return; + } + if (!perteForm.motif.trim()) { setPerteErr('Le motif est obligatoire.'); return; } + setPerteSaving(true); + try { + await api.post(`/investissements/${id}/perte-definitive`, { + date_effet: perteForm.date_effet, + montant_perte: Number(perteForm.montant_perte), + motif: perteForm.motif.trim(), + }); + setPerteModalOpen(false); + await load(); + } catch (e) { + const fieldErrors = e.details?.fieldErrors; + const detail = fieldErrors ? Object.values(fieldErrors).flat().filter(Boolean).join(' ') : ''; + setPerteErr(detail || e.message); + } finally { setPerteSaving(false); } + }; + + const cancelPerteDeclaration = async () => { + try { + await api.del(`/investissements/${id}/perte-definitive`); + setConfirmingPerteCancel(false); + await load(); + } catch (e) { setConfirmingPerteCancel(false); } + }; + // Rendement annualisé (XIRR) — calcul exact une fois le prêt intégralement remboursé. // Sinon (en cours / en retard / procédure), estimation basée sur les remboursements déjà // versés + une valorisation du capital restant dû à la date du jour (comme si ce capital @@ -950,6 +1037,11 @@ export default function InvestissementDetail() {
{fmtEUR(capRestant)}
+ {capitalPerdu > 0 && ( +
+ dont {fmtEUR(capitalPerdu)} déclarés en perte définitive +
+ )}
Intérêts perçus — {netMode ? 'Net' : 'Brut'}
@@ -1116,6 +1208,77 @@ export default function InvestissementDetail() {
+ {/* ── Perte(s) définitive(s) déclarée(s) ──────────────────────── */} + {pertes.length > 0 && ( +
+

Perte définitive

+
+
+ {[...pertes].reverse().map((perte, idx) => { + const isLast = idx === 0; // dernière déclaration (liste inversée) + const isActive = isLast && inv.statut === 'perte_definitive'; + const ancienMeta = STATUT_META[perte.ancien_statut] || { label: perte.ancien_statut, color: '#6b7280', bg: 'rgba(107,114,128,0.12)' }; + return ( +
+
+
+
+ ⚠ Perte définitive déclarée + Effet au {fmtDate(perte.date_effet)} + {isActive && !confirmingPerteCancel && ( + + )} +
+ +
+ Montant perdu + {fmtEUR(perte.montant_perte)} +
+
+ Statut avant déclaration + + {ancienMeta.label} + +
+
+ Motif : {perte.motif} +
+
+ Déclarée le {fmtDate(perte.created_at)} +
+
+
+ ); + })} +
+
+ )} + {/* ── Révisions des conditions de prêt ──────────────────────── */} {revisions.length > 0 && (
@@ -1148,19 +1311,34 @@ export default function InvestissementDetail() { ⟳ Révision des conditions Effet au {fmtDate(rev.date_effet)} {isLast && !isConfirmingDelete && ( - +
+ + +
)}
@@ -1206,6 +1384,26 @@ export default function InvestissementDetail() {
)} + {rev.nouveau_statut && ( +
+ Statut + {(() => { + const from = STATUT_META[rev.ancien_statut] || { label: rev.ancien_statut, color: '#6b7280', bg: 'rgba(107,114,128,0.12)' }; + const to = STATUT_META[rev.nouveau_statut] || { label: rev.nouveau_statut, color: '#6b7280', bg: 'rgba(107,114,128,0.12)' }; + return ( + <> + + {from.label} + + → + + {to.label} + + + ); + })()} +
+ )}
Motif : {rev.motif}
@@ -1503,7 +1701,11 @@ export default function InvestissementDetail() { ? matchedAll.reduce((sum, r) => sum + (r.net_recu || 0), 0) : matchedAll.reduce((sum, r) => sum + (r.capital || 0) + (r.cashback || 0) + (r.interets_bruts || 0), 0)) : null; + const capitalRecu = isPaid + ? matchedAll.reduce((sum, r) => sum + (r.capital || 0), 0) + : null; const isPast = s.date_prevue < todayStr; + const capitalDiffers = isPaid && Math.abs(capitalRecu - s.capital_prevu) > 0.01; const interetsDiffers = isPaid && Math.abs(interetsReels - interets) > 0.01; const totalDiffers = isPaid && Math.abs(totalRecu - s.total_prevu) > 0.01; @@ -1518,7 +1720,14 @@ export default function InvestissementDetail() { > {s.numero_echeance} {fmtDate(s.date_prevue)} - {fmtEUR(s.capital_prevu)} + + {capitalDiffers ? ( + + {fmtEUR(s.capital_prevu)} + {fmtEUR(capitalRecu)} + + ) : fmtEUR(s.capital_prevu)} + {interetsDiffers ? ( @@ -1590,7 +1799,7 @@ export default function InvestissementDetail() { const fmtVal = (champ, val) => { if (val === null || val === undefined) return '—'; const typeLabels = { in_fine: 'In fine', amortissable: 'Amortissable', differe: 'Différé' }; - const statutLabels = { en_cours: 'En cours', rembourse: 'Remboursé', en_retard: 'En retard', procedure: 'Procédure', cloture: 'Clôturé' }; + const statutLabels = { en_cours: 'En cours', rembourse: 'Remboursé', en_retard: 'En retard', procedure: 'Procédure', cloture: 'Clôturé', perte_definitive: 'Perte définitive' }; const freqLabels = { mensuel: 'Mensuel', trimestriel: 'Trimestriel', in_fine: 'In fine (unique)' }; if (champ === 'type_remb') return typeLabels[val] ?? val; if (champ === 'statut') return statutLabels[val] ?? val; @@ -1735,21 +1944,22 @@ export default function InvestissementDetail() { {/* ── Modal révision des conditions de prêt ─────────────────── */} } >

- À utiliser lorsqu'un événement (retard projet, renégociation…) modifie le taux et/ou la date cible - du prêt. L'échéancier de projection sera régénéré automatiquement à partir de la date d'effet. + {revisionEditId + ? "Corrige la révision existante (l'historique n'ajoute pas une nouvelle entrée). L'échéancier de projection sera régénéré." + : "À utiliser lorsqu'un événement (retard projet, renégociation…) modifie le taux et/ou la date cible du prêt. L'échéancier de projection sera régénéré automatiquement à partir de la date d'effet."}

@@ -1809,6 +2019,15 @@ export default function InvestissementDetail() {
La durée du prêt sera recalculée automatiquement pour que l'échéancier atteigne cette date.
+ {['en_retard', 'procedure'].includes(inv.statut) && ( +
+ Le statut passera automatiquement à « Prolongation » — le dossier sortira temporairement + du scope « à risque » jusqu'à ce que cette nouvelle date soit à son tour dépassée. +
+ )} )} @@ -2186,6 +2405,7 @@ export default function InvestissementDetail() { +
@@ -2653,10 +2873,21 @@ export default function InvestissementDetail() { { label: 'Modifier', icon: <>, action: () => { setCardMenu(null); openEdit(); } }, { label: 'Réinvestir', icon: <>, action: () => { setCardMenu(null); openReinvModal(); } }, { label: 'Réviser les conditions', icon: <>, action: () => { setCardMenu(null); openRevisionModal(); } }, + ['en_retard', 'procedure'].includes(inv.statut) && { + label: 'Déclarer une perte définitive', + icon: <>, + danger: true, + action: () => { setCardMenu(null); openPerteModal(); }, + }, + inv.statut === 'perte_definitive' && { + label: 'Annuler la déclaration de perte', + icon: <>, + action: () => { setCardMenu(null); setConfirmingPerteCancel(true); }, + }, { label: 'Exporter', icon: <>, action: () => { setCardMenu(null); exportDossier(); } }, - ].map(({ label, icon, action }) => ( + ].filter(Boolean).map(({ label, icon, action, danger }) => ( + + + } + > +

+ À utiliser uniquement lorsque le capital est réellement irrécouvrable (procédure + collective actée, émetteur radié…) — pas un simple retard de paiement. Une fois + déclaré, ce dossier sort du capital investi/en risque et le montant est reporté + en case 2TY (pertes en capital) de votre synthèse fiscale, pour l'année de la date + d'effet ci-dessous. +

+ + + setPerteForm(f => ({ ...f, date_effet: e.target.value }))} + required + /> + + setPerteForm(f => ({ ...f, montant_perte: e.target.value }))} + /> +
+ Préfilli avec le capital restant dû — ajustez si une partie a déjà été récupérée + (perte partielle). +
+ +