Mise en place de la notion de perte définitive, ainsi que la révision avec statut Polongation

This commit is contained in:
ocroguennec committed 2026-08-22 16:27:41 +02:00
1 parent 4ad6ea79e8
commit 8e1b82d161
21 files changed
+1069 -184

No files matched your search

+151
View File
@@ -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 ───────────────────────────────────────── // ── Migration : rôle utilisateur ─────────────────────────────────────────
{ {
const userCols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name); 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; export default db;
+2 -2
View File
@@ -87,7 +87,7 @@ CREATE INDEX IF NOT EXISTS idx_depret_date ON depots_retraits(date_operation);
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
-- INVESTISSEMENTS (CF Investissements - liste des projets souscrits) -- 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 ( CREATE TABLE IF NOT EXISTS investissements (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
@@ -104,7 +104,7 @@ CREATE TABLE IF NOT EXISTS investissements (
type_remb TEXT, -- 'in_fine' | 'amortissable' | 'differe' type_remb TEXT, -- 'in_fine' | 'amortissable' | 'differe'
freq_interets TEXT NOT NULL DEFAULT 'mensuel', -- 'mensuel' | 'trimestriel' | 'in_fine' freq_interets TEXT NOT NULL DEFAULT 'mensuel', -- 'mensuel' | 'trimestriel' | 'in_fine'
statut TEXT NOT NULL DEFAULT 'en_cours' 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 reference TEXT, -- ID projet sur la plateforme
source TEXT NOT NULL DEFAULT 'manuel', source TEXT NOT NULL DEFAULT 'manuel',
notes TEXT, notes TEXT,
+15 -6
View File
@@ -28,9 +28,14 @@ function notifyUser(userId, { type, title, body, link }) {
/** /**
* Passe automatiquement au statut "en_retard" les investissements dont : * 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 * - 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 * Chaque passage est tracé dans investissement_historique avec le
* type_evenement 'passage_auto_retard' pour conserver l'auditabilité, et * type_evenement 'passage_auto_retard' pour conserver l'auditabilité, et
* génère une notification utilisateur. * génère une notification utilisateur.
@@ -39,10 +44,10 @@ function notifyUser(userId, { type, title, body, link }) {
*/ */
function checkPassagesEnRetard() { function checkPassagesEnRetard() {
const candidats = db.prepare(` 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 FROM investissements i
JOIN investisseurs inv ON inv.id = i.investisseur_id 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 IS NOT NULL
AND i.date_cible < date('now') AND i.date_cible < date('now')
`).all(); `).all();
@@ -69,15 +74,19 @@ function checkPassagesEnRetard() {
JSON.stringify([{ JSON.stringify([{
champ: 'statut', champ: 'statut',
label: 'Statut', label: 'Statut',
ancienne_valeur: 'en_cours', ancienne_valeur: inv.statut,
nouvelle_valeur: 'en_retard', 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, { notifyUser(inv.user_id, {
type: 'warning', type: 'warning',
title: `Prêt en retard : "${inv.nom_projet}"`, 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}`, link: `/investissements/${inv.id}`,
}); });
} }
+16 -2
View File
@@ -704,7 +704,9 @@ router.get('/interets-par-plateforme', (req, res) => {
i.date_souscription, i.date_souscription,
i.date_cible, i.date_cible,
i.statut, 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 FROM investissements i
WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')} WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')}
`).all(...invParams); `).all(...invParams);
@@ -792,10 +794,22 @@ router.get('/interets-par-plateforme', (req, res) => {
.reduce((s, r) => s + r.montant, 0); .reduce((s, r) => s + r.montant, 0);
const capitalRestant = Math.max(0, inv.montant_investi + reinvestsCumul - capitalRembCumul - capitalSimulCumul); 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) { if (actif) {
capital += capitalRestant; capital += capitalRestant;
if (['en_retard', 'procedure'].includes(inv.statut)) enDefaut += 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 { } else {
// Remboursé/clôturé : était-il encore actif ce mois-ci ? // Remboursé/clôturé : était-il encore actif ce mois-ci ?
// Priorité : date_cible → dernier remboursement réel → exclure // Priorité : date_cible → dernier remboursement réel → exclure
+236 -15
View File
@@ -3,7 +3,7 @@ import { z } from 'zod';
import db from '../db/index.js'; import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js'; import { HttpError } from '../middleware/errorHandler.js';
import { requireInvestisseur } from '../middleware/investisseurScope.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'; import { checkStatutsRetard } from '../jobs/autoStatut.js';
const router = Router(); 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é', 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) { 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( const hasReinvest = db.prepare(
'SELECT 1 FROM reinvestissements WHERE investissement_id = ? LIMIT 1' 'SELECT 1 FROM reinvestissements WHERE investissement_id = ? LIMIT 1'
).get(investissementId); ).get(investissementId);
if (hasReinvest) { if (hasReinvest) {
generateSimulWithReinvestissements(db, investissementId); generateSimulWithReinvestissements(db, investissementId);
} else { } 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(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({ 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.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(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(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 i.montant_investi + (SELECT COALESCE(SUM(rv.montant),0) FROM reinvestissements rv WHERE rv.investissement_id = i.id) AS capital_total
FROM investissements i FROM investissements i
JOIN plateformes p ON p.id = i.plateforme_id JOIN plateformes p ON p.id = i.plateforme_id
@@ -425,6 +460,9 @@ router.get('/:id', (req, res, next) => {
const revisions = db.prepare( const revisions = db.prepare(
'SELECT * FROM investissement_revisions WHERE investissement_id = ? ORDER BY id ASC' 'SELECT * FROM investissement_revisions WHERE investissement_id = ? ORDER BY id ASC'
).all(req.params.id); ).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( const reinvestissements = db.prepare(
'SELECT * FROM reinvestissements WHERE investissement_id = ? ORDER BY date_reinvestissement' 'SELECT * FROM reinvestissements WHERE investissement_id = ? ORDER BY date_reinvestissement'
).all(req.params.id); ).all(req.params.id);
@@ -445,7 +483,7 @@ router.get('/:id', (req, res, next) => {
WHERE is2.investissement_id = ? WHERE is2.investissement_id = ?
ORDER BY is_global DESC, s.nom ORDER BY is_global DESC, s.nom
`).all(req.params.id); `).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); } } 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 // 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 // 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. // 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) => { router.post('/:id/revisions', (req, res, next) => {
try { try {
const body = RevisionSchema.parse(req.body); 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é // 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 // 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). // + (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; 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; 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; let revisionId;
const tx = db.transaction(() => { const tx = db.transaction(() => {
const r = db.prepare(` const r = db.prepare(`
INSERT INTO investissement_revisions INSERT INTO investissement_revisions
(investissement_id, date_effet, ancien_taux, nouveau_taux, ancienne_date_cible, nouvelle_date_cible, (investissement_id, date_effet, ancien_taux, nouveau_taux, ancienne_date_cible, nouvelle_date_cible,
ancien_duree_mois, nouveau_duree_mois, motif) ancien_duree_mois, nouveau_duree_mois, ancien_statut, nouveau_statut, motif)
VALUES (?,?,?,?,?,?,?,?,?) VALUES (?,?,?,?,?,?,?,?,?,?,?)
`).run(invId, body.date_effet, ancien_taux, nouveau_taux, ancienne_date_cible, nouvelle_date_cible, `).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; 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(` db.prepare(`
UPDATE investissements 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 = ? 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); regenererEcheancier(invId);
}); });
@@ -670,7 +726,7 @@ router.delete('/:id/revisions/:rid', (req, res, next) => {
try { try {
const invId = Number(req.params.id); const invId = Number(req.params.id);
const inv = db.prepare(` 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 JOIN investisseurs inv ON inv.id = i.investisseur_id
WHERE i.id = ? AND inv.user_id = ? WHERE i.id = ? AND inv.user_id = ?
`).get(invId, req.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'); 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(() => { const tx = db.transaction(() => {
db.prepare(` db.prepare(`
UPDATE investissements UPDATE investissements
SET taux_interet = ?, date_cible = ?, duree_mois = ?, SET taux_interet = ?, date_cible = ?, duree_mois = ?,
statut = COALESCE(?, statut),
date_debut_simul = ( date_debut_simul = (
SELECT date_effet FROM investissement_revisions SELECT date_effet FROM investissement_revisions
WHERE investissement_id = ? AND id != ? ORDER BY id DESC LIMIT 1 WHERE investissement_id = ? AND id != ? ORDER BY id DESC LIMIT 1
), ),
updated_at = datetime('now') updated_at = datetime('now')
WHERE id = ? 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); 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); } } 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 } // PUT /api/investissements/:id/auto-reinvest { active: true|false }
router.put('/:id/auto-reinvest', (req, res, next) => { router.put('/:id/auto-reinvest', (req, res, next) => {
try { try {
+8 -1
View File
@@ -3,7 +3,7 @@ import { z } from 'zod';
import db from '../db/index.js'; import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js'; import { HttpError } from '../middleware/errorHandler.js';
import { requireInvestisseur } from '../middleware/investisseurScope.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(); const router = Router();
@@ -149,7 +149,14 @@ router.post('/recalculate', (req, res, next) => {
const { investissement_id } = req.body; const { investissement_id } = req.body;
if (!investissement_id) throw new HttpError(400, 'investissement_id required'); if (!investissement_id) throw new HttpError(400, 'investissement_id required');
assertOwnedInvestissement(Number(investissement_id), req.user.id); 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)); adjustSimulForActuals(db, Number(investissement_id));
replayAdjustSimulForActuals(db, Number(investissement_id));
const rows = db.prepare( const rows = db.prepare(
'SELECT * FROM simul_remboursements WHERE investissement_id=? ORDER BY numero_echeance' 'SELECT * FROM simul_remboursements WHERE investissement_id=? ORDER BY numero_echeance'
).all(investissement_id); ).all(investissement_id);
+14 -13
View File
@@ -59,19 +59,21 @@ router.get('/', (req, res) => {
recap.net_recu += recapInteretsPlateforme.net_recu; recap.net_recu += recapInteretsPlateforme.net_recu;
recap.nb_remboursements += recapInteretsPlateforme.nb_remboursements; 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(` const pertes = db.prepare(`
SELECT i.id, i.nom_projet, p.nom AS plateforme_nom, SELECT i.id, i.nom_projet, p.nom AS plateforme_nom,
i.montant_investi, i.montant_investi,
COALESCE((SELECT SUM(r.capital) FROM remboursements r WHERE r.investissement_id = i.id),0) AS capital_rembourse, COALESCE((SELECT SUM(r.capital) FROM remboursements r WHERE r.investissement_id = i.id),0) AS capital_rembourse,
(i.montant_investi - ip.montant_perte AS perte_capital,
COALESCE((SELECT SUM(r.capital) FROM remboursements r WHERE r.investissement_id = i.id),0) i.statut, ip.date_effet
) AS perte_capital,
i.statut, i.updated_at
FROM investissements i FROM investissements i
JOIN plateformes p ON p.id = i.plateforme_id JOIN plateformes p ON p.id = i.plateforme_id
JOIN investissement_pertes ip ON ip.investissement_id = i.id
WHERE ${invCond} WHERE ${invCond}
AND i.statut IN ('en_retard','cloture') AND i.statut = 'perte_definitive'
AND substr(i.updated_at,1,4) = ? AND substr(ip.date_effet,1,4) = ?
`).all(invArg, annee); `).all(invArg, annee);
const pertesTotales = pertes.reduce((s, p) => s + Math.max(0, p.perte_capital), 0); 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) => const rowsMerged = Object.values(rowsMap).sort((a, b) =>
a.plateforme_nom.localeCompare(b.plateforme_nom) || (a.investisseur_nom ?? '').localeCompare(b.investisseur_nom ?? '')); 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(` const pertesRows = db.prepare(`
SELECT SELECT
p.id AS plateforme_id, p.id AS plateforme_id,
i.investisseur_id, i.investisseur_id,
COALESCE(SUM( COALESCE(SUM(ip.montant_perte), 0) AS perte_capital
i.montant_investi -
COALESCE((SELECT SUM(r2.capital) FROM remboursements r2 WHERE r2.investissement_id = i.id),0)
), 0) AS perte_capital
FROM investissements i FROM investissements i
JOIN plateformes p ON p.id = i.plateforme_id JOIN plateformes p ON p.id = i.plateforme_id
JOIN investissement_pertes ip ON ip.investissement_id = i.id
WHERE ${invCond} WHERE ${invCond}
AND i.statut IN ('en_retard','cloture') AND i.statut = 'perte_definitive'
AND substr(i.updated_at,1,4) = ? AND substr(ip.date_effet,1,4) = ?
GROUP BY p.id, i.investisseur_id GROUP BY p.id, i.investisseur_id
`).all(invArg, annee); `).all(invArg, annee);
+1 -1
View File
@@ -21,7 +21,7 @@ const LIST_COLUMNS = `
* parameters: * parameters:
* - in: query * - in: query
* name: statut * 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: * responses:
* 200: { description: Liste des investissements } * 200: { description: Liste des investissements }
*/ */
+6 -2
View File
@@ -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 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()) : // 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 // - 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). // 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 // - 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 // 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). // ne s'alimente que si la déclaration 2778-SD est active).
const frLignes = cerfa2561.lignes.filter(l => l.domiciliation === 'FR'); 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 platEtr = cerfa2778_mensuel?.plateformes ?? [];
const etrBA = p => Object.values(p.mois ?? {}).reduce((s, v) => s + v, 0); const etrBA = p => Object.values(p.mois ?? {}).reduce((s, v) => s + v, 0);
const totalBA = platEtr.reduce((s, p) => s + etrBA(p), 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_2TR: sumBy(frLignes, 'case_2TR') + Math.round(totalBA),
case_2BH: sumBy(frLignes, 'case_2BH') + (pfoAssujetti ? Math.round(totalBA) : 0), 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_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({ res.json({
+185 -78
View File
@@ -158,7 +158,78 @@ export function adjustSimulForActuals(db, investissementId) {
'SELECT MAX(date_remb) AS last_date FROM remboursements WHERE investissement_id = ? AND capital > 0' 'SELECT MAX(date_remb) AS last_date FROM remboursements WHERE investissement_id = ? AND capital > 0'
).get(investissementId); ).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) // (initial + réinvestissements survenus avant ou à cette date)
const capitalAtLastDate = round2( const capitalAtLastDate = round2(
inv.montant_investi + inv.montant_investi +
@@ -167,25 +238,25 @@ export function adjustSimulForActuals(db, investissementId) {
const remainingCapital = round2(capitalAtLastDate - total_capital); 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(` const futureEntries = db.prepare(`
SELECT * FROM simul_remboursements SELECT * FROM simul_remboursements
WHERE investissement_id = ? AND date_prevue > ? WHERE investissement_id = ? AND date_prevue > ?
ORDER BY numero_echeance ORDER BY numero_echeance
`).all(investissementId, last_date); `).all(inv.id, last_date);
if (futureEntries.length === 0) return; if (futureEntries.length === 0) return;
// Capital restant soldé → on met tout à zéro, et on porte le capital sur l'échéance courante // Capital restant soldé → on met tout à zéro, et on porte le capital sur l'échéance courante
if (remainingCapital <= 0) { 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(` const lastPaidEntry = db.prepare(`
SELECT id, interets_prevus, capital_prevu SELECT id, interets_prevus, capital_prevu
FROM simul_remboursements FROM simul_remboursements
WHERE investissement_id = ? AND date_prevue <= ? WHERE investissement_id = ? AND date_prevue <= ?
ORDER BY date_prevue DESC ORDER BY date_prevue DESC
LIMIT 1 LIMIT 1
`).get(investissementId, last_date); `).get(inv.id, last_date);
db.transaction(() => { db.transaction(() => {
// Supprimer les échéances futures devenues caduques // Supprimer les échéances futures devenues caduques
@@ -213,7 +284,7 @@ export function adjustSimulForActuals(db, investissementId) {
const nFuture = futureEntries.length; const nFuture = futureEntries.length;
const type = inv.type_remb || 'in_fine'; 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 futureReinvests = reinvests.filter(r => r.date_reinvestissement > last_date);
const updates = []; 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); 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) // taux_interet peut légitimement valoir 0 (cf. commentaire dans adjustSimulForActuals)
if (inv.taux_interet == null || !inv.duree_mois) return; 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 finDeMois = !!inv.echeance_fin_de_mois;
const type = inv.type_remb || 'in_fine'; const type = inv.type_remb || 'in_fine';
const freq = inv.freq_interets || 'mensuel'; const freq = inv.freq_interets || 'mensuel';
const step = freq === 'trimestriel' ? 3 : 1; const step = freq === 'trimestriel' ? 3 : 1;
const rPer = (inv.taux_interet / 100 / 12) * step; const rPer = (inv.taux_interet / 100 / 12) * step;
const isRestructuration = !!inv.date_debut_simul;
// Durée effective (tient compte d'une éventuelle restructuration) // En mode restructuration, il faut connaître les échéances déjà honorées AVANT de
let effectiveDuree = inv.duree_mois; // calculer la date de départ de la suite de l'échéancier (cf. generateSimul() pour le
if (inv.date_debut_simul && inv.date_premiere_echeance && inv.date_debut_simul > inv.date_premiere_echeance) { // détail du raisonnement) — simple lecture, peut se faire hors transaction.
const elapsed = monthsDiff(inv.date_premiere_echeance, inv.date_debut_simul); let keptEntries = [];
effectiveDuree = Math.max(1, inv.duree_mois - elapsed); 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 // Calendrier de base pour obtenir les dates de chaque échéance
@@ -463,24 +560,10 @@ export function generateSimulWithReinvestissements(db, investissementId) {
} }
db.transaction(() => { db.transaction(() => {
if (inv.date_debut_simul) { if (isRestructuration) {
// ── Mode restructuration ────────────────────────────────────────────── // ── Mode restructuration ──────────────────────────────────────────────
// Même logique que generateSimul() : conserver les échéances déjà payées avant // keptEntries/elapsedMonths déjà calculés plus haut (nécessaire pour la date de
// la date de restructuration, supprimer le reste, et renuméroter à partir du // départ du calendrier de base) : on les réutilise tels quels ici.
// 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);
if (keptEntries.length > 0) { if (keptEntries.length > 0) {
db.prepare( db.prepare(
`DELETE FROM simul_remboursements WHERE investissement_id = ? AND id NOT IN (${keptEntries.map(() => '?').join(',')})` `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); 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(` const stmt = db.prepare(`
INSERT INTO simul_remboursements INSERT INTO simul_remboursements
(investissement_id, numero_echeance, date_prevue, capital_prevu, interets_prevus, total_prevu) (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. * 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. * Ne fait rien si taux_interet ou duree_mois est absent.
* *
* Si date_debut_simul est renseigné (restructuration de prêt), la simulation * Si date_debut_simul est renseigné (restructuration de prêt suite à une révision des
* démarre à cette date et la durée effective est réduite du nombre de mois déjà * conditions), la durée effective est réduite du nombre de mois déjà écoulés depuis
* écoulés depuis date_premiere_echeance, afin de ne pas allonger artificiellement le prêt. * 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) { export function generateSimul(db, inv) {
const { id, montant_investi, taux_interet, duree_mois, type_remb, freq_interets, 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) // taux_interet peut légitimement valoir 0 (cf. commentaire dans adjustSimulForActuals)
if (taux_interet == null || !duree_mois) return; if (taux_interet == null || !duree_mois) return;
// date_debut_simul remplace le point de départ quand le prêt a été restructuré const finDeMois = !!echeance_fin_de_mois;
const startDate = date_debut_simul || date_premiere_echeance || date_souscription; const isRestructuration = !!date_debut_simul;
if (!startDate) return;
// Durée effective : si restructuration, on soustrait les mois déjà écoulés // En mode restructuration, on a besoin de connaître les échéances déjà honorées
// pour que la simulation se termine bien à la date cible contractuelle d'origine. // AVANT de calculer la date de départ de la suite de l'échéancier (cf. plus bas) —
let effectiveDuree = duree_mois; // simple lecture, sans effet de bord, peut se faire hors transaction.
if (date_debut_simul && date_premiere_echeance && date_debut_simul > date_premiere_echeance) { let keptEntries = [];
const elapsed = monthsDiff(date_premiere_echeance, date_debut_simul); if (isRestructuration) {
effectiveDuree = Math.max(1, duree_mois - elapsed); // 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({ const echeances = buildSchedule({
@@ -550,28 +683,11 @@ export function generateSimul(db, inv) {
type: type_remb || 'in_fine', type: type_remb || 'in_fine',
freq: freq_interets || 'mensuel', freq: freq_interets || 'mensuel',
startDate, startDate,
finDeMois: !!echeance_fin_de_mois, finDeMois,
}); });
const tx = db.transaction(() => { const tx = db.transaction(() => {
if (date_debut_simul) { if (isRestructuration) {
// ── Mode restructuration ──────────────────────────────────────────────
// Conserver uniquement les échéances déjà payées avant la date de restructuration
// (correspondance par mois YYYY-MM avec les remboursements réels enregistrés).
// Les échéances de la période creuse (non payées entre fin de la phase initiale
// et date_debut_simul) sont supprimées avec tout ce qui suit.
const keptEntries = db.prepare(`
SELECT sr.id, sr.numero_echeance FROM simul_remboursements sr
WHERE sr.investissement_id = ?
AND sr.date_prevue < ?
AND EXISTS (
SELECT 1 FROM remboursements r
WHERE r.investissement_id = sr.investissement_id
AND substr(r.date_remb, 1, 7) = substr(sr.date_prevue, 1, 7)
)
ORDER BY sr.numero_echeance
`).all(id, date_debut_simul);
if (keptEntries.length > 0) { if (keptEntries.length > 0) {
// Supprime tout sauf les entrées payées conservées // Supprime tout sauf les entrées payées conservées
db.prepare( db.prepare(
@@ -582,15 +698,6 @@ export function generateSimul(db, inv) {
db.prepare('DELETE FROM simul_remboursements WHERE investissement_id=?').run(id); 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(` const stmt = db.prepare(`
INSERT INTO simul_remboursements INSERT INTO simul_remboursements
(investissement_id, numero_echeance, date_prevue, capital_prevu, interets_prevus, total_prevu) (investissement_id, numero_echeance, date_prevue, capital_prevu, interets_prevus, total_prevu)
@@ -89,18 +89,27 @@ export default function CapitalMensuelTable({ allRows, allRembs, allReinvests, p
const { grid, multiDetenteur } = useMemo(() => { const { grid, multiDetenteur } = useMemo(() => {
if (!allRows.length) return { grid: null, multiDetenteur: false }; 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) // Index plateformes par id (pour nom + detenteur)
const platMap = {}; const platMap = {};
for (const p of plats) platMap[p.id] = p; 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 getCapitalAtEndOfMonth = (inv, Y, M) => {
const endM = endOfMonth(Y, M); const endM = endOfMonth(Y, M);
if (inv.date_souscription > endM) return 0; if (inv.date_souscription > endM) return 0;
const startM = startOfMonth(Y, M); 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); ((inv.date_cible || lastRembDateMap[inv.id] || null) >= startM);
if (!isActive) return 0; if (!isActive) return 0;
@@ -112,7 +121,11 @@ export default function CapitalMensuelTable({ allRows, allRembs, allReinvests, p
.filter(rb => rb.date && rb.date <= endM) .filter(rb => rb.date && rb.date <= endM)
.reduce((s, rb) => s + rb.capital, 0); .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) // Agréger par plateforme (id)
+17 -4
View File
@@ -53,13 +53,22 @@ export default function InvMensuelTable({ rows, allRembs, allReinvests, year, or
return map; return map;
}, [allRembs]); }, [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 getCapital = (inv, Y, M) => {
const endM = endOfMonth(Y, M); const endM = endOfMonth(Y, M);
if (inv.date_souscription > endM) return 0; if (inv.date_souscription > endM) return 0;
const startM = startOfMonth(Y, M); const startM = startOfMonth(Y, M);
const ACTIVE = ['en_cours', 'en_retard', 'procedure']; // prolongation compte comme actif au même titre que en_retard/procedure dont il
const isActive = ACTIVE.includes(inv.statut) || // 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); ((inv.date_cible || lastRembDateMap[inv.id] || null) >= startM);
if (!isActive) return 0; if (!isActive) return 0;
@@ -69,7 +78,11 @@ export default function InvMensuelTable({ rows, allRembs, allReinvests, year, or
const capRembM = (capRembByInv[inv.id] || []) const capRembM = (capRembByInv[inv.id] || [])
.filter(rb => rb.date && rb.date <= endM) .filter(rb => rb.date && rb.date <= endM)
.reduce((s, rb) => s + rb.capital, 0); .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 ── */ /* ── Grille : une ligne par investissement ── */
+15 -3
View File
@@ -156,7 +156,7 @@ function TrendBadge({ current, prev, invert = false }) {
} }
/* ── Carte KPI individuelle ── */ /* ── Carte KPI individuelle ── */
function KpiCard({ title, value, badge, refValue, onClick, hint }) { function KpiCard({ title, value, badge, refValue, note, onClick, hint }) {
return ( return (
<div <div
className="kpi" className="kpi"
@@ -174,12 +174,21 @@ function KpiCard({ title, value, badge, refValue, onClick, hint }) {
{refValue && ( {refValue && (
<div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{refValue}</div> <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{refValue}</div>
)} )}
{note && (
<div style={{ fontSize: '0.8em', color: '#7c2d12', marginTop: 4, fontWeight: 600 }}>{note}</div>
)}
</div> </div>
); );
} }
/* ── KPI filtrés par année ── */ /* ── 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 { const {
annee, modeGlobal, rawDataGlobal, rawData, currentYear, currentMonth, annee, modeGlobal, rawDataGlobal, rawData, currentYear, currentMonth,
setInclureInterets, setInclureCapital, setInclureCashback, setInclureInterets, setInclureCapital, setInclureCashback,
@@ -451,6 +460,9 @@ function DashboardKpis({ portfolio, netMode, pfuRates, capitalMensuelData, activ
refValue={isCurrentYear && enDefautPrev != null && enDefautPrev > 0 refValue={isCurrentYear && enDefautPrev != null && enDefautPrev > 0
? fmtEUR(enDefautPrev) + ' en ' + prevMonthLabel ? fmtEUR(enDefautPrev) + ' en ' + prevMonthLabel
: null} : null}
note={capitalPertesDefinitives > 0
? `dont ${fmtEUR(capitalPertesDefinitives)} déjà déclarés en perte définitive (non compris ci-dessus)`
: null}
/> />
<KpiCard <KpiCard
title="Porte-monnaie" title="Porte-monnaie"
@@ -622,7 +634,7 @@ export default function Dashboard() {
<DashboardKpis <DashboardKpis
portfolio={portfolio} netMode={netMode} pfuRates={pfuRates} capitalMensuelData={capitalMensuelData} portfolio={portfolio} netMode={netMode} pfuRates={pfuRates} capitalMensuelData={capitalMensuelData}
activeId={activeId} activeView={activeView} soldePortefeuilleLive={data?.solde_portefeuille_total ?? null} activeId={activeId} activeView={activeView} soldePortefeuilleLive={data?.solde_portefeuille_total ?? null}
correctionsTotal={data?.corrections_total ?? 0} correctionsTotal={data?.corrections_total ?? 0} investissements={investissements}
/> />
<YearSelectorKpi /> <YearSelectorKpi />
</div> </div>
+322 -30
View File
@@ -67,11 +67,13 @@ function typeLabel(type_remb, freq_interets) {
} }
const STATUT_META = { const STATUT_META = {
en_cours: { label: 'En cours', color: '#3b82f6', bg: 'rgba(59,130,246,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)' }, 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)' }, 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)' }, 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)' }, 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 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) // Modal révision des conditions de prêt (taux/date suite à un événement)
const [revisionModalOpen, setRevisionModalOpen] = useState(false); 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({ const [revisionForm, setRevisionForm] = useState({
date_effet: today(), date_effet: today(),
changeTaux: false, nouveau_taux: '', changeTaux: false, nouveau_taux: '',
@@ -146,6 +150,12 @@ export default function InvestissementDetail() {
const [revisionErr, setRevisionErr] = useState(null); const [revisionErr, setRevisionErr] = useState(null);
const [revisionSaving, setRevisionSaving] = useState(false); const [revisionSaving, setRevisionSaving] = useState(false);
const [confirmingRevisionDelete, setConfirmingRevisionDelete] = useState(null); // id de la révision à supprimer 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 [loading, setLoading] = useState(true);
const [recalculating, setRecalculating] = useState(false); const [recalculating, setRecalculating] = useState(false);
// Modal réinvestissement // Modal réinvestissement
@@ -609,6 +619,7 @@ export default function InvestissementDetail() {
}; };
const openRevisionModal = () => { const openRevisionModal = () => {
setRevisionEditId(null);
setRevisionForm({ setRevisionForm({
date_effet: today(), date_effet: today(),
changeTaux: false, nouveau_taux: '', changeTaux: false, nouveau_taux: '',
@@ -618,7 +629,22 @@ export default function InvestissementDetail() {
setRevisionErr(null); setRevisionErr(null);
setRevisionModalOpen(true); 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) => { const submitRevision = async (e) => {
e?.preventDefault?.(); e?.preventDefault?.();
@@ -639,13 +665,19 @@ export default function InvestissementDetail() {
} }
setRevisionSaving(true); setRevisionSaving(true);
try { try {
await api.post(`/investissements/${id}/revisions`, { const payload = {
date_effet: revisionForm.date_effet, date_effet: revisionForm.date_effet,
nouveau_taux: revisionForm.changeTaux ? Number(revisionForm.nouveau_taux) : undefined, nouveau_taux: revisionForm.changeTaux ? Number(revisionForm.nouveau_taux) : undefined,
nouvelle_date_cible: revisionForm.changeDate ? revisionForm.nouvelle_date_cible : undefined, nouvelle_date_cible: revisionForm.changeDate ? revisionForm.nouvelle_date_cible : undefined,
motif: revisionForm.motif.trim(), motif: revisionForm.motif.trim(),
}); };
if (revisionEditId) {
await api.put(`/investissements/${id}/revisions/${revisionEditId}`, payload);
} else {
await api.post(`/investissements/${id}/revisions`, payload);
}
setRevisionModalOpen(false); setRevisionModalOpen(false);
setRevisionEditId(null);
await load(); await load();
} catch (e) { } catch (e) {
// Surface le détail des erreurs de validation (Zod) plutôt que le message générique // 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 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 lastDateCibleRevision = [...revisions].reverse().find(r => String(r.ancienne_date_cible) !== String(r.nouvelle_date_cible));
const reinvs = inv.reinvestissements || []; const reinvs = inv.reinvestissements || [];
const pertes = inv.pertes || [];
// Capital total = montant initial + réinvestissements // Capital total = montant initial + réinvestissements
const capitalTotal = inv.capital_total ?? inv.montant_investi; const capitalTotal = inv.capital_total ?? inv.montant_investi;
const autoReinvActive = !!inv?.auto_reinvest; 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; 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) // 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 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 interetsPercus = remb.reduce((s, r) => s + (r.interets_bruts || 0), 0);
const interetsPercusNet = remb.reduce((s, r) => s + (r.interets_nets || 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, motif: r.motif,
created_at: r.created_at, 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 blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@@ -858,6 +901,50 @@ export default function InvestissementDetail() {
URL.revokeObjectURL(url); 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é. // 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à // 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 // versés + une valorisation du capital restant dû à la date du jour (comme si ce capital
@@ -950,6 +1037,11 @@ export default function InvestissementDetail() {
<div className="value" style={{ color: capRestant === 0 ? 'var(--success)' : undefined }}> <div className="value" style={{ color: capRestant === 0 ? 'var(--success)' : undefined }}>
{fmtEUR(capRestant)} {fmtEUR(capRestant)}
</div> </div>
{capitalPerdu > 0 && (
<div className="text-muted" style={{ fontSize: 11, marginTop: 2, color: '#7c2d12' }}>
dont {fmtEUR(capitalPerdu)} déclarés en perte définitive
</div>
)}
</div> </div>
<div className="kpi"> <div className="kpi">
<div className="label">Intérêts perçus — {netMode ? 'Net' : 'Brut'}</div> <div className="label">Intérêts perçus — {netMode ? 'Net' : 'Brut'}</div>
@@ -1116,6 +1208,77 @@ export default function InvestissementDetail() {
</div> </div>
</div> </div>
{/* ── Perte(s) définitive(s) déclarée(s) ──────────────────────── */}
{pertes.length > 0 && (
<div className="card" style={{ marginBottom: 16, border: '1px solid rgba(239,68,68,0.35)' }}>
<h3 style={{ marginTop: 0, marginBottom: 16, color: '#ef4444' }}>Perte définitive</h3>
<div style={{ position: 'relative', paddingLeft: 28 }}>
<div style={{
position: 'absolute', left: 9, top: 0, bottom: 0,
width: 2, background: 'var(--border, rgba(255,255,255,.1))',
}} />
{[...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 (
<div key={perte.id} style={{ position: 'relative', marginBottom: idx < pertes.length - 1 ? 16 : 0 }}>
<div style={{
position: 'absolute', left: -24, top: 14,
width: 10, height: 10, borderRadius: '50%',
background: '#ef4444',
border: '2px solid var(--bg-card, #1e293b)',
zIndex: 1,
}} />
<div style={{
background: 'rgba(239,68,68,0.07)',
border: confirmingPerteCancel ? '1px solid rgba(239,68,68,0.5)' : '1px solid rgba(239,68,68,0.3)',
borderRadius: 8, padding: '10px 14px',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8, flexWrap: 'wrap' }}>
<span style={{ fontWeight: 700, fontSize: 13, color: '#ef4444' }}>⚠ Perte définitive déclarée</span>
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>Effet au {fmtDate(perte.date_effet)}</span>
{isActive && !confirmingPerteCancel && (
<button
title="Annuler cette déclaration de perte"
onClick={() => setConfirmingPerteCancel(true)}
style={{
marginLeft: 'auto', background: 'transparent', border: 'none',
cursor: 'pointer', padding: '2px 6px', borderRadius: 4,
color: 'var(--text-muted)', fontSize: 14, lineHeight: 1,
}}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}
>
🗑
</button>
)}
</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, flexWrap: 'wrap', fontSize: 12, padding: '3px 0' }}>
<span style={{ color: 'var(--text-muted)', minWidth: 130 }}>Montant perdu</span>
<span style={{ fontWeight: 700, color: '#ef4444' }}>{fmtEUR(perte.montant_perte)}</span>
</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, flexWrap: 'wrap', fontSize: 12, padding: '3px 0' }}>
<span style={{ color: 'var(--text-muted)', minWidth: 130 }}>Statut avant déclaration</span>
<span style={{ background: ancienMeta.bg, color: ancienMeta.color, borderRadius: 4, padding: '1px 6px', fontWeight: 600 }}>
{ancienMeta.label}
</span>
</div>
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--text)', lineHeight: 1.4 }}>
<span className="text-muted">Motif : </span>{perte.motif}
</div>
<div style={{ marginTop: 6, fontSize: 11, color: 'var(--text-muted)' }}>
Déclarée le {fmtDate(perte.created_at)}
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{/* ── Révisions des conditions de prêt ──────────────────────── */} {/* ── Révisions des conditions de prêt ──────────────────────── */}
{revisions.length > 0 && ( {revisions.length > 0 && (
<div className="card" style={{ marginBottom: 16 }}> <div className="card" style={{ marginBottom: 16 }}>
@@ -1148,19 +1311,34 @@ export default function InvestissementDetail() {
<span style={{ fontWeight: 700, fontSize: 13, color: '#a78bfa' }}>⟳ Révision des conditions</span> <span style={{ fontWeight: 700, fontSize: 13, color: '#a78bfa' }}>⟳ Révision des conditions</span>
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>Effet au {fmtDate(rev.date_effet)}</span> <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>Effet au {fmtDate(rev.date_effet)}</span>
{isLast && !isConfirmingDelete && ( {isLast && !isConfirmingDelete && (
<button <div style={{ marginLeft: 'auto', display: 'flex', gap: 2 }}>
title="Supprimer cette révision (rollback)" <button
onClick={() => setConfirmingRevisionDelete(rev.id)} title="Corriger cette révision"
style={{ onClick={() => openEditRevisionModal(rev)}
marginLeft: 'auto', background: 'transparent', border: 'none', style={{
cursor: 'pointer', padding: '2px 6px', borderRadius: 4, background: 'transparent', border: 'none',
color: 'var(--text-muted)', fontSize: 14, lineHeight: 1, cursor: 'pointer', padding: '2px 6px', borderRadius: 4,
}} color: 'var(--text-muted)', fontSize: 14, lineHeight: 1,
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} }}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'} onMouseEnter={e => e.currentTarget.style.color = '#a78bfa'}
> onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}
🗑 >
</button> ✏️
</button>
<button
title="Supprimer cette révision (rollback)"
onClick={() => setConfirmingRevisionDelete(rev.id)}
style={{
background: 'transparent', border: 'none',
cursor: 'pointer', padding: '2px 6px', borderRadius: 4,
color: 'var(--text-muted)', fontSize: 14, lineHeight: 1,
}}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}
>
🗑
</button>
</div>
)} )}
</div> </div>
@@ -1206,6 +1384,26 @@ export default function InvestissementDetail() {
</span> </span>
</div> </div>
)} )}
{rev.nouveau_statut && (
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, flexWrap: 'wrap', fontSize: 12, padding: '3px 0' }}>
<span style={{ color: 'var(--text-muted)', minWidth: 100 }}>Statut</span>
{(() => {
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 (
<>
<span style={{ background: from.bg, color: from.color, borderRadius: 4, padding: '1px 6px', opacity: 0.8 }}>
{from.label}
</span>
<span style={{ color: 'var(--text-muted)', fontSize: 10 }}>→</span>
<span style={{ background: to.bg, color: to.color, borderRadius: 4, padding: '1px 6px' }}>
{to.label}
</span>
</>
);
})()}
</div>
)}
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--text)', lineHeight: 1.4 }}> <div style={{ marginTop: 8, fontSize: 12, color: 'var(--text)', lineHeight: 1.4 }}>
<span className="text-muted">Motif : </span>{rev.motif} <span className="text-muted">Motif : </span>{rev.motif}
</div> </div>
@@ -1503,7 +1701,11 @@ export default function InvestissementDetail() {
? matchedAll.reduce((sum, r) => sum + (r.net_recu || 0), 0) ? 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)) : matchedAll.reduce((sum, r) => sum + (r.capital || 0) + (r.cashback || 0) + (r.interets_bruts || 0), 0))
: null; : null;
const capitalRecu = isPaid
? matchedAll.reduce((sum, r) => sum + (r.capital || 0), 0)
: null;
const isPast = s.date_prevue < todayStr; 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 interetsDiffers = isPaid && Math.abs(interetsReels - interets) > 0.01;
const totalDiffers = isPaid && Math.abs(totalRecu - s.total_prevu) > 0.01; const totalDiffers = isPaid && Math.abs(totalRecu - s.total_prevu) > 0.01;
@@ -1518,7 +1720,14 @@ export default function InvestissementDetail() {
> >
<td className="num">{s.numero_echeance}</td> <td className="num">{s.numero_echeance}</td>
<td>{fmtDate(s.date_prevue)}</td> <td>{fmtDate(s.date_prevue)}</td>
<td className="num">{fmtEUR(s.capital_prevu)}</td> <td className="num">
{capitalDiffers ? (
<span style={{ display: 'inline-flex', alignItems: 'baseline', gap: 5 }}>
<span style={{ textDecoration: 'line-through', color: 'var(--text-muted)', fontWeight: 400 }}>{fmtEUR(s.capital_prevu)}</span>
<span>{fmtEUR(capitalRecu)}</span>
</span>
) : fmtEUR(s.capital_prevu)}
</td>
<td className="num"> <td className="num">
{interetsDiffers ? ( {interetsDiffers ? (
<span style={{ display: 'inline-flex', alignItems: 'baseline', gap: 5 }}> <span style={{ display: 'inline-flex', alignItems: 'baseline', gap: 5 }}>
@@ -1590,7 +1799,7 @@ export default function InvestissementDetail() {
const fmtVal = (champ, val) => { const fmtVal = (champ, val) => {
if (val === null || val === undefined) return '—'; if (val === null || val === undefined) return '—';
const typeLabels = { in_fine: 'In fine', amortissable: 'Amortissable', differe: 'Différé' }; 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)' }; const freqLabels = { mensuel: 'Mensuel', trimestriel: 'Trimestriel', in_fine: 'In fine (unique)' };
if (champ === 'type_remb') return typeLabels[val] ?? val; if (champ === 'type_remb') return typeLabels[val] ?? val;
if (champ === 'statut') return statutLabels[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 ─────────────────── */} {/* ── Modal révision des conditions de prêt ─────────────────── */}
<Modal <Modal
open={revisionModalOpen} open={revisionModalOpen}
title="Réviser les conditions du prêt" title={revisionEditId ? 'Corriger la révision' : 'Réviser les conditions du prêt'}
onClose={closeRevisionModal} onClose={closeRevisionModal}
width={520} width={520}
footer={ footer={
<> <>
<button className="ghost" onClick={closeRevisionModal}>Annuler</button> <button className="ghost" onClick={closeRevisionModal}>Annuler</button>
<button onClick={submitRevision} disabled={revisionSaving}> <button onClick={submitRevision} disabled={revisionSaving}>
{revisionSaving ? 'Enregistrement…' : 'Enregistrer la révision'} {revisionSaving ? 'Enregistrement…' : (revisionEditId ? 'Enregistrer la correction' : 'Enregistrer la révision')}
</button> </button>
</> </>
} }
> >
<p className="text-muted" style={{ fontSize: 13, marginTop: 0 }}> <p className="text-muted" style={{ fontSize: 13, marginTop: 0 }}>
À utiliser lorsqu'un événement (retard projet, renégociation…) modifie le taux et/ou la date cible {revisionEditId
du prêt. L'échéancier de projection sera régénéré automatiquement à partir de la date d'effet. ? "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."}
</p> </p>
<form onSubmit={submitRevision}> <form onSubmit={submitRevision}>
<label>Date d'effet</label> <label>Date d'effet</label>
@@ -1809,6 +2019,15 @@ export default function InvestissementDetail() {
<div className="text-muted" style={{ fontSize: 11, marginTop: 4 }}> <div className="text-muted" style={{ fontSize: 11, marginTop: 4 }}>
La durée du prêt sera recalculée automatiquement pour que l'échéancier atteigne cette date. La durée du prêt sera recalculée automatiquement pour que l'échéancier atteigne cette date.
</div> </div>
{['en_retard', 'procedure'].includes(inv.statut) && (
<div style={{
fontSize: 11, marginTop: 6, padding: '6px 10px', borderRadius: 6,
background: 'rgba(139,92,246,0.12)', color: '#8b5cf6', fontWeight: 600,
}}>
Le statut passera automatiquement à « Prolongation » — le dossier sortira temporairement
du scope « à risque » jusqu'à ce que cette nouvelle date soit à son tour dépassée.
</div>
)}
</> </>
)} )}
<label style={{ marginTop: 10, display: 'block' }}>Motif de la révision</label> <label style={{ marginTop: 10, display: 'block' }}>Motif de la révision</label>
@@ -2186,6 +2405,7 @@ export default function InvestissementDetail() {
<option value="en_retard">En retard</option> <option value="en_retard">En retard</option>
<option value="procedure">Procédure</option> <option value="procedure">Procédure</option>
<option value="cloture">Clôturé</option> <option value="cloture">Clôturé</option>
<option value="perte_definitive">Perte définitive</option>
</select> </select>
</div> </div>
<div> <div>
@@ -2653,10 +2873,21 @@ export default function InvestissementDetail() {
{ label: 'Modifier', icon: <><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></>, action: () => { setCardMenu(null); openEdit(); } }, { label: 'Modifier', icon: <><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></>, action: () => { setCardMenu(null); openEdit(); } },
{ label: 'Réinvestir', icon: <><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></>, action: () => { setCardMenu(null); openReinvModal(); } }, { label: 'Réinvestir', icon: <><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></>, action: () => { setCardMenu(null); openReinvModal(); } },
{ label: 'Réviser les conditions', icon: <><path d="M21 12a9 9 0 1 1-2.64-6.36"/><polyline points="21 3 21 9 15 9"/></>, action: () => { setCardMenu(null); openRevisionModal(); } }, { label: 'Réviser les conditions', icon: <><path d="M21 12a9 9 0 1 1-2.64-6.36"/><polyline points="21 3 21 9 15 9"/></>, action: () => { setCardMenu(null); openRevisionModal(); } },
['en_retard', 'procedure'].includes(inv.statut) && {
label: 'Déclarer une perte définitive',
icon: <><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></>,
danger: true,
action: () => { setCardMenu(null); openPerteModal(); },
},
inv.statut === 'perte_definitive' && {
label: 'Annuler la déclaration de perte',
icon: <><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-9.36L1 10"/></>,
action: () => { setCardMenu(null); setConfirmingPerteCancel(true); },
},
{ label: 'Exporter', icon: <><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></>, action: () => { setCardMenu(null); exportDossier(); } }, { label: 'Exporter', icon: <><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></>, action: () => { setCardMenu(null); exportDossier(); } },
].map(({ label, icon, action }) => ( ].filter(Boolean).map(({ label, icon, action, danger }) => (
<button key={label} <button key={label}
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: 'var(--text)', textAlign: 'left' }} style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: danger ? 'var(--danger)' : 'var(--text)', textAlign: 'left' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'} onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'} onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={() => action()}> onClick={() => action()}>
@@ -2707,6 +2938,67 @@ export default function InvestissementDetail() {
onConfirm={rowDeleteConfirm?.onConfirm} onConfirm={rowDeleteConfirm?.onConfirm}
onCancel={() => setRowDeleteConfirm(null)} onCancel={() => setRowDeleteConfirm(null)}
/> />
{/* ── Modal déclaration de perte définitive ─────────────────── */}
<Modal
open={perteModalOpen}
title="Déclarer une perte définitive"
onClose={closePerteModal}
width={520}
footer={
<>
<button className="ghost" onClick={closePerteModal}>Annuler</button>
<button className="danger" onClick={submitPerte} disabled={perteSaving}>
{perteSaving ? 'Enregistrement…' : 'Déclarer la perte'}
</button>
</>
}
>
<p className="text-muted" style={{ fontSize: 13, marginTop: 0 }}>
À 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.
</p>
<form onSubmit={submitPerte}>
<label>Date d'effet (constat de la perte)</label>
<input
type="date"
value={perteForm.date_effet}
onChange={e => setPerteForm(f => ({ ...f, date_effet: e.target.value }))}
required
/>
<label style={{ marginTop: 12, display: 'block' }}>
Montant de la perte <span className="text-muted">— capital restant dû : {fmtEUR(capRestant)}</span>
</label>
<input
type="number" step="0.01" min="0" required
value={perteForm.montant_perte}
onChange={e => setPerteForm(f => ({ ...f, montant_perte: e.target.value }))}
/>
<div className="text-muted" style={{ fontSize: 11, marginTop: 4 }}>
Préfilli avec le capital restant dû — ajustez si une partie a déjà été récupérée
(perte partielle).
</div>
<label style={{ marginTop: 12, display: 'block' }}>Motif</label>
<textarea
rows={3}
placeholder="Ex : liquidation judiciaire de l'émetteur, clôture de la procédure de recouvrement…"
value={perteForm.motif}
onChange={e => setPerteForm(f => ({ ...f, motif: e.target.value }))}
required
/>
{perteErr && <div className="error" style={{ marginTop: 10 }}>{perteErr}</div>}
</form>
</Modal>
<ConfirmModal
open={confirmingPerteCancel}
message="Annuler la déclaration de perte définitive ? Le dossier reprendra son statut précédent et sortira de la case 2TY."
onConfirm={cancelPerteDeclaration}
onCancel={() => setConfirmingPerteCancel(false)}
/>
</> </>
); );
} }
+21 -8
View File
@@ -90,7 +90,7 @@ function invToCSV(rows) {
function invToXLS(rows) { function invToXLS(rows) {
const data = rows.map(r => { const data = rows.map(r => {
const capInv = r.capital_total ?? r.montant_investi; const capInv = r.capital_total ?? r.montant_investi;
const capRestant = Math.max(0, capInv - (r.capital_rembourse || 0)); const capRestant = Math.max(0, capInv - (r.capital_rembourse || 0) - (r.capital_perdu || 0));
return { return {
'Date souscription': r.date_souscription, 'Date souscription': r.date_souscription,
'Projet': r.nom_projet, 'Projet': r.nom_projet,
@@ -429,7 +429,11 @@ export default function Investissements() {
const decStart = `${yr}-12-01`; const decStart = `${yr}-12-01`;
if (r.date_souscription > cutoff) return false; // pas encore souscrit if (r.date_souscription > cutoff) return false; // pas encore souscrit
const ACTIVE = ['en_cours', 'en_retard', 'procedure']; const ACTIVE = ['en_cours', 'en_retard', 'procedure'];
if (ACTIVE.includes(r.statut)) return true; // encore actif aujourd'hui // perte_definitive et prolongation restent "actifs" au sens de ce filtre (comme
// en_retard/procedure dont ils découlent) : le dossier ne doit pas disparaître des
// vues par année sous prétexte que la perte a été déclarée ou qu'une prolongation a
// été négociée — voir project_perte_definitive.md et project_prolongation.md
if (ACTIVE.includes(r.statut) || ['perte_definitive', 'prolongation'].includes(r.statut)) return true; // encore actif aujourd'hui
const fin = r.date_cible ?? lastRembDateMap[r.id] ?? null; const fin = r.date_cible ?? lastRembDateMap[r.id] ?? null;
return !!fin && fin >= decStart; // clôturé mais actif en décembre return !!fin && fin >= decStart; // clôturé mais actif en décembre
}; };
@@ -490,11 +494,12 @@ export default function Investissements() {
acc.cap_remb += capRemb; acc.cap_remb += capRemb;
acc.int_perc += rembParInv[r.id]?.interets_bruts || 0; acc.int_perc += rembParInv[r.id]?.interets_bruts || 0;
acc.int_perc_net += rembParInv[r.id]?.interets_nets || 0; acc.int_perc_net += rembParInv[r.id]?.interets_nets || 0;
const capRestant = Math.max(0, capInv - capRemb); const capRestant = Math.max(0, capInv - capRemb - (r.capital_perdu || 0));
acc.encours += capRestant; acc.encours += capRestant;
if (['en_retard', 'procedure'].includes(r.statut)) acc.defaut += capRestant; if (['en_retard', 'procedure'].includes(r.statut)) acc.defaut += capRestant;
acc.pertes += (r.capital_perdu || 0);
return acc; return acc;
}, { investi: 0, cap_remb: 0, int_perc: 0, int_perc_net: 0, encours: 0, defaut: 0 }), [kpiRows, capRembParInv, rembParInv, reinvestCumulParInv]); }, { investi: 0, cap_remb: 0, int_perc: 0, int_perc_net: 0, encours: 0, defaut: 0, pertes: 0 }), [kpiRows, capRembParInv, rembParInv, reinvestCumulParInv]);
/* ── Totaux KPI année N-1 (pour TrendBadge) ── */ /* ── Totaux KPI année N-1 (pour TrendBadge) ── */
const prevTotals = useMemo(() => { const prevTotals = useMemo(() => {
@@ -530,7 +535,7 @@ export default function Investissements() {
acc.cap_remb += capRemb; acc.cap_remb += capRemb;
acc.int_perc += prevRembParInv[r.id]?.interets_bruts || 0; acc.int_perc += prevRembParInv[r.id]?.interets_bruts || 0;
acc.int_perc_net += prevRembParInv[r.id]?.interets_nets || 0; acc.int_perc_net += prevRembParInv[r.id]?.interets_nets || 0;
const capRestant = Math.max(0, capInv - capRemb); const capRestant = Math.max(0, capInv - capRemb - (r.capital_perdu || 0));
acc.encours += capRestant; acc.encours += capRestant;
if (['en_retard', 'procedure'].includes(r.statut)) acc.defaut += capRestant; if (['en_retard', 'procedure'].includes(r.statut)) acc.defaut += capRestant;
return acc; return acc;
@@ -563,7 +568,7 @@ export default function Investissements() {
p.cap_remb += capRemb; p.cap_remb += capRemb;
p.int_perc += rembParInv[r.id]?.interets_bruts || 0; p.int_perc += rembParInv[r.id]?.interets_bruts || 0;
p.int_perc_net += rembParInv[r.id]?.interets_nets || 0; p.int_perc_net += rembParInv[r.id]?.interets_nets || 0;
const capRestant = Math.max(0, capInv - capRemb); const capRestant = Math.max(0, capInv - capRemb - (r.capital_perdu || 0));
// Sans filtre année : les prêts remboursés ont capRestant=0 → pas d'impact // Sans filtre année : les prêts remboursés ont capRestant=0 → pas d'impact
// Avec filtre année : on veut le capital déployé à la coupure, quel que soit le statut actuel // Avec filtre année : on veut le capital déployé à la coupure, quel que soit le statut actuel
p.encours += capRestant; p.encours += capRestant;
@@ -607,7 +612,7 @@ export default function Investissements() {
.map(r => { .map(r => {
const capInv = r.montant_investi + (reinvestCumulParInv[r.id] || 0); const capInv = r.montant_investi + (reinvestCumulParInv[r.id] || 0);
const capRemb = capRembParInv[r.id] || 0; const capRemb = capRembParInv[r.id] || 0;
const capRestant = Math.max(0, capInv - capRemb); const capRestant = Math.max(0, capInv - capRemb - (r.capital_perdu || 0));
return { plateforme_nom: r.plateforme_nom, montant: capRestant }; return { plateforme_nom: r.plateforme_nom, montant: capRestant };
}) })
.filter(r => r.montant > 0), .filter(r => r.montant > 0),
@@ -847,6 +852,11 @@ export default function Investissements() {
{platYear && totals.defaut > 0 && <TrendBadge current={totals.defaut} prev={prevTotals.defaut} invert={true} />} {platYear && totals.defaut > 0 && <TrendBadge current={totals.defaut} prev={prevTotals.defaut} invert={true} />}
</div> </div>
{platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.defaut)} en {Number(platYear) - 1}</div>} {platYear && <div style={{ fontSize: '0.8em', color: 'var(--text-muted)', marginTop: 5 }}>{fmtEUR(prevTotals.defaut)} en {Number(platYear) - 1}</div>}
{totals.pertes > 0 && (
<div style={{ fontSize: '0.8em', color: '#7c2d12', marginTop: 4, fontWeight: 600 }}>
dont {fmtEUR(totals.pertes)} déjà déclarés en perte définitive (non compris ci-dessus)
</div>
)}
</div> </div>
{/* 3 — Investissements depuis le début */} {/* 3 — Investissements depuis le début */}
@@ -1148,6 +1158,8 @@ export default function Investissements() {
<option value="en_retard">En retard</option> <option value="en_retard">En retard</option>
<option value="procedure">Procédure</option> <option value="procedure">Procédure</option>
<option value="cloture">Clôturé</option> <option value="cloture">Clôturé</option>
<option value="perte_definitive">Perte définitive</option>
<option value="prolongation">Prolongation</option>
</select> </select>
</div> </div>
<div style={{ flex: 1, minWidth: 120 }}> <div style={{ flex: 1, minWidth: 120 }}>
@@ -1221,7 +1233,7 @@ export default function Investissements() {
)} )}
{pagedRows.map(r => { {pagedRows.map(r => {
const capInv = r.capital_total ?? r.montant_investi; const capInv = r.capital_total ?? r.montant_investi;
const capRestant = Math.max(0, capInv - (r.capital_rembourse || 0)); const capRestant = Math.max(0, capInv - (r.capital_rembourse || 0) - (r.capital_perdu || 0));
return ( return (
<tr key={r.id} <tr key={r.id}
onClick={() => navigate(`/investissements/${r.id}`, { state: { from: { path: '/investissements', search: location.search, label: 'Investissements' } } })} onClick={() => navigate(`/investissements/${r.id}`, { state: { from: { path: '/investissements', search: location.search, label: 'Investissements' } } })}
@@ -1560,6 +1572,7 @@ export default function Investissements() {
<option value="en_retard">En retard</option> <option value="en_retard">En retard</option>
<option value="procedure">Procédure</option> <option value="procedure">Procédure</option>
<option value="cloture">Clôturé</option> <option value="cloture">Clôturé</option>
<option value="perte_definitive">Perte définitive</option>
</select> </select>
</div> </div>
)} )}
+17 -7
View File
@@ -752,7 +752,11 @@ export default function Plateformes() {
const decStart = `${yr}-12-01`; const decStart = `${yr}-12-01`;
if (r.date_souscription > cutoff) return false; if (r.date_souscription > cutoff) return false;
const ACTIVE = ['en_cours', 'en_retard', 'procedure']; const ACTIVE = ['en_cours', 'en_retard', 'procedure'];
if (ACTIVE.includes(r.statut)) return true; // perte_definitive et prolongation restent "actifs" au sens de ce filtre (comme
// en_retard/procedure dont ils découlent) : le dossier ne doit pas disparaître des
// vues par année sous prétexte que la perte a été déclarée ou qu'une prolongation a
// été négociée — voir project_perte_definitive.md et project_prolongation.md
if (ACTIVE.includes(r.statut) || ['perte_definitive', 'prolongation'].includes(r.statut)) return true;
const fin = r.date_cible ?? lastRembDateMap[r.id] ?? null; const fin = r.date_cible ?? lastRembDateMap[r.id] ?? null;
return !!fin && fin >= decStart; return !!fin && fin >= decStart;
}; };
@@ -805,11 +809,12 @@ export default function Plateformes() {
acc.cap_remb += capRemb; acc.cap_remb += capRemb;
acc.int_perc += rembParInv[r.id]?.interets_bruts || 0; acc.int_perc += rembParInv[r.id]?.interets_bruts || 0;
acc.int_perc_net += rembParInv[r.id]?.interets_nets || 0; acc.int_perc_net += rembParInv[r.id]?.interets_nets || 0;
const capRestant = Math.max(0, capInv - capRemb); const capRestant = Math.max(0, capInv - capRemb - (r.capital_perdu || 0));
acc.encours += capRestant; acc.encours += capRestant;
if (['en_retard', 'procedure'].includes(r.statut)) acc.defaut += capRestant; if (['en_retard', 'procedure'].includes(r.statut)) acc.defaut += capRestant;
acc.pertes += (r.capital_perdu || 0);
return acc; return acc;
}, { investi: 0, cap_remb: 0, int_perc: 0, int_perc_net: 0, encours: 0, defaut: 0 }), }, { investi: 0, cap_remb: 0, int_perc: 0, int_perc_net: 0, encours: 0, defaut: 0, pertes: 0 }),
[chartRows, capRembParInv, rembParInv, reinvestCumulParInv]); [chartRows, capRembParInv, rembParInv, reinvestCumulParInv]);
/* ── Pagination onglet Investissements ── */ /* ── Pagination onglet Investissements ── */
@@ -853,7 +858,7 @@ export default function Plateformes() {
acc.cap_remb += capRemb; acc.cap_remb += capRemb;
acc.int_perc += prevRembParInv[r.id]?.interets_bruts || 0; acc.int_perc += prevRembParInv[r.id]?.interets_bruts || 0;
acc.int_perc_net += prevRembParInv[r.id]?.interets_nets || 0; acc.int_perc_net += prevRembParInv[r.id]?.interets_nets || 0;
const capRestant = Math.max(0, capInv - capRemb); const capRestant = Math.max(0, capInv - capRemb - (r.capital_perdu || 0));
acc.encours += capRestant; acc.encours += capRestant;
if (['en_retard', 'procedure'].includes(r.statut)) acc.defaut += capRestant; if (['en_retard', 'procedure'].includes(r.statut)) acc.defaut += capRestant;
return acc; return acc;
@@ -891,7 +896,7 @@ export default function Plateformes() {
for (const r of chartRows) { for (const r of chartRows) {
const capInv = r.montant_investi + (reinvestCumulParInv[r.id] || 0); const capInv = r.montant_investi + (reinvestCumulParInv[r.id] || 0);
const capRemb = capRembParInv[r.id] || 0; const capRemb = capRembParInv[r.id] || 0;
const capRestant = Math.max(0, capInv - capRemb); const capRestant = Math.max(0, capInv - capRemb - (r.capital_perdu || 0));
if (capRestant > 0.01) { if (capRestant > 0.01) {
estime = true; estime = true;
flowsBrut.push({ amount: capRestant, date: cutoff }); flowsBrut.push({ amount: capRestant, date: cutoff });
@@ -951,7 +956,7 @@ export default function Plateformes() {
for (const r of chartRows) { for (const r of chartRows) {
const capInv = r.montant_investi + (reinvestCumulParInv[r.id] || 0); const capInv = r.montant_investi + (reinvestCumulParInv[r.id] || 0);
const capRemb = capRembParInv[r.id] || 0; const capRemb = capRembParInv[r.id] || 0;
const capRestant = Math.max(0, capInv - capRemb); const capRestant = Math.max(0, capInv - capRemb - (r.capital_perdu || 0));
if (capRestant <= 0.01) continue; if (capRestant <= 0.01) continue;
const echeances = futureSimulParInv[r.id]; const echeances = futureSimulParInv[r.id];
if (echeances && echeances.length) { if (echeances && echeances.length) {
@@ -1560,6 +1565,11 @@ export default function Plateformes() {
{fmtEUR(prevTotals.defaut)} en {prevYearLabel} {fmtEUR(prevTotals.defaut)} en {prevYearLabel}
</div> </div>
)} )}
{totals.pertes > 0 && (
<div style={{ fontSize: '0.8em', color: '#7c2d12', marginTop: 4, fontWeight: 600 }}>
dont {fmtEUR(totals.pertes)} déjà déclarés en perte définitive (non compris ci-dessus)
</div>
)}
</div> </div>
{/* 3 — Porte-monnaie */} {/* 3 — Porte-monnaie */}
@@ -2557,7 +2567,7 @@ export default function Plateformes() {
{pagedChartRows.map(r => { {pagedChartRows.map(r => {
const capInv = r.montant_investi + (reinvestCumulParInv[r.id] || 0); const capInv = r.montant_investi + (reinvestCumulParInv[r.id] || 0);
const capRemb = capRembParInv[r.id] || 0; const capRemb = capRembParInv[r.id] || 0;
const capRestant = Math.max(0, capInv - capRemb); const capRestant = Math.max(0, capInv - capRemb - (r.capital_perdu || 0));
const interets = netMode const interets = netMode
? (rembParInv[r.id]?.interets_nets || 0) ? (rembParInv[r.id]?.interets_nets || 0)
: (rembParInv[r.id]?.interets_bruts || 0); : (rembParInv[r.id]?.interets_bruts || 0);
+4 -1
View File
@@ -266,6 +266,9 @@ export default function TaxReport() {
]).then(([d, d2561, d2778]) => { ]).then(([d, d2561, d2778]) => {
setData(d); setData(d);
const frLignes = (d2561?.lignes ?? []).filter(l => l.domiciliation === 'FR'); const frLignes = (d2561?.lignes ?? []).filter(l => l.domiciliation === 'FR');
// case_2TY (pertes en capital) n'est pas spécifique FR/étranger contrairement aux autres
// cases — une perte définitive sur une plateforme étrangère doit compter aussi.
const allLignes = d2561?.lignes ?? [];
const platEtr = (d2778?.plateformes ?? []).filter(p => !excluded.has(p.id)); const platEtr = (d2778?.plateformes ?? []).filter(p => !excluded.has(p.id));
const etrBA = p => Object.values(p.mois ?? {}).reduce((s, v) => s + v, 0); const etrBA = p => Object.values(p.mois ?? {}).reduce((s, v) => s + v, 0);
const pfo = 0.128; // taux par défaut — affiné par pfuList si dispo const pfo = 0.128; // taux par défaut — affiné par pfuList si dispo
@@ -274,7 +277,7 @@ export default function TaxReport() {
case_2TR: frLignes.reduce((s, l) => s + (l.case_2TR ?? 0), 0) + Math.round(platEtr.reduce((s, p) => s + etrBA(p), 0)), case_2TR: frLignes.reduce((s, l) => s + (l.case_2TR ?? 0), 0) + Math.round(platEtr.reduce((s, p) => s + etrBA(p), 0)),
case_2BH: frLignes.reduce((s, l) => s + (l.case_2BH ?? 0), 0) + (pfoAssujetti ? Math.round(platEtr.reduce((s, p) => s + etrBA(p), 0)) : 0), case_2BH: frLignes.reduce((s, l) => s + (l.case_2BH ?? 0), 0) + (pfoAssujetti ? Math.round(platEtr.reduce((s, p) => s + etrBA(p), 0)) : 0),
case_2CK: frLignes.reduce((s, l) => s + (l.case_2CK ?? 0), 0) + (pfoAssujetti ? Math.round(platEtr.reduce((s, p) => s + etrBA(p), 0) * pfo) : 0), case_2CK: frLignes.reduce((s, l) => s + (l.case_2CK ?? 0), 0) + (pfoAssujetti ? Math.round(platEtr.reduce((s, p) => s + etrBA(p), 0) * pfo) : 0),
case_2TY: frLignes.reduce((s, l) => s + (l.case_2TY ?? 0), 0), case_2TY: allLignes.reduce((s, l) => s + (l.case_2TY ?? 0), 0),
}); });
}).finally(() => setLoading(false)); }).finally(() => setLoading(false));
}; };
+6
View File
@@ -51,6 +51,8 @@
--b-en_retard-bg:#fee2e2; --b-en_retard-fg:#991b1b; --b-en_retard-bg:#fee2e2; --b-en_retard-fg:#991b1b;
--b-procedure-bg:#fef3c7;--b-procedure-fg:#92400e; --b-procedure-bg:#fef3c7;--b-procedure-fg:#92400e;
--b-cloture-bg: #e2e8f0; --b-cloture-fg: #475569; --b-cloture-bg: #e2e8f0; --b-cloture-fg: #475569;
--b-perte_definitive-bg:#374151; --b-perte_definitive-fg:#f9fafb;
--b-prolongation-bg:#ede9fe; --b-prolongation-fg:#6d28d9;
--b-depot-bg: #dcfce7; --b-depot-fg: #166534; --b-depot-bg: #dcfce7; --b-depot-fg: #166534;
--b-retrait-bg: #fee2e2; --b-retrait-fg: #991b1b; --b-retrait-bg: #fee2e2; --b-retrait-fg: #991b1b;
--b-paye-bg: #dcfce7; --b-paye-fg: #166534; --b-paye-bg: #dcfce7; --b-paye-fg: #166534;
@@ -89,6 +91,8 @@
--b-en_retard-bg:#7f1d1d; --b-en_retard-fg:#fecaca; --b-en_retard-bg:#7f1d1d; --b-en_retard-fg:#fecaca;
--b-procedure-bg:#78350f;--b-procedure-fg:#fde68a; --b-procedure-bg:#78350f;--b-procedure-fg:#fde68a;
--b-cloture-bg: #1e3a6a; --b-cloture-fg: #93b4d8; --b-cloture-bg: #1e3a6a; --b-cloture-fg: #93b4d8;
--b-perte_definitive-bg:#111827; --b-perte_definitive-fg:#e5e7eb;
--b-prolongation-bg:#4c1d95; --b-prolongation-fg:#ddd6fe;
--b-depot-bg: #14532d; --b-depot-fg: #bbf7d0; --b-depot-bg: #14532d; --b-depot-fg: #bbf7d0;
--b-retrait-bg: #7f1d1d; --b-retrait-fg: #fecaca; --b-retrait-bg: #7f1d1d; --b-retrait-fg: #fecaca;
--b-paye-bg: #14532d; --b-paye-fg: #bbf7d0; --b-paye-bg: #14532d; --b-paye-fg: #bbf7d0;
@@ -630,6 +634,8 @@ tr:hover td { background: var(--surface-2); }
.badge.en_retard { background: var(--b-en_retard-bg); color: var(--b-en_retard-fg); } .badge.en_retard { background: var(--b-en_retard-bg); color: var(--b-en_retard-fg); }
.badge.procedure { background: var(--b-procedure-bg); color: var(--b-procedure-fg); } .badge.procedure { background: var(--b-procedure-bg); color: var(--b-procedure-fg); }
.badge.cloture { background: var(--b-cloture-bg); color: var(--b-cloture-fg); } .badge.cloture { background: var(--b-cloture-bg); color: var(--b-cloture-fg); }
.badge.perte_definitive { background: var(--b-perte_definitive-bg); color: var(--b-perte_definitive-fg); }
.badge.prolongation { background: var(--b-prolongation-bg); color: var(--b-prolongation-fg); }
.badge.depot { background: var(--b-depot-bg); color: var(--b-depot-fg); } .badge.depot { background: var(--b-depot-bg); color: var(--b-depot-fg); }
.badge.retrait { background: var(--b-retrait-bg); color: var(--b-retrait-fg); } .badge.retrait { background: var(--b-retrait-bg); color: var(--b-retrait-fg); }
.badge.paye { background: var(--b-paye-bg); color: var(--b-paye-fg); } .badge.paye { background: var(--b-paye-bg); color: var(--b-paye-fg); }
+8 -1
View File
@@ -41,7 +41,14 @@ export function capitalEncoursParInvestissement(investissements, remboursementsC
const d = rb.date_remb?.slice(0, 10); const d = rb.date_remb?.slice(0, 10);
if (d && d <= cutoff) capRemb += rb.capital || 0; if (d && d <= cutoff) capRemb += rb.capital || 0;
} }
map[inv.id] = Math.max(0, capInv - capRemb); // Perte définitive déclarée (capital_perdu/perte_date_effet, cf. GET /api/investissements) :
// déduite uniquement à partir du mois de déclaration, pour rester fidèle à l'historique
// (le capital comptait normalement avant que la perte ne soit actée).
let capPerdu = 0;
if (inv.capital_perdu && inv.perte_date_effet && inv.perte_date_effet <= cutoff) {
capPerdu = inv.capital_perdu;
}
map[inv.id] = Math.max(0, capInv - capRemb - capPerdu);
} }
return map; return map;
} }
+7 -5
View File
@@ -65,11 +65,13 @@ export const memberLabel = (m) => {
/* ── Statuts investissements ───────────────────────────────── */ /* ── Statuts investissements ───────────────────────────────── */
export const STATUT_LABELS = { export const STATUT_LABELS = {
en_cours: 'En cours', en_cours: 'En cours',
rembourse: 'Remboursé', rembourse: 'Remboursé',
en_retard: 'En retard', en_retard: 'En retard',
procedure: 'Procédure', procedure: 'Procédure',
cloture: 'Clôturé', cloture: 'Clôturé',
perte_definitive: 'Perte définitive',
prolongation: 'Prolongation',
}; };
/** Retourne le libellé affiché d'un statut (première lettre majuscule). */ /** Retourne le libellé affiché d'un statut (première lettre majuscule). */
+1 -1
View File
@@ -104,7 +104,7 @@ export function registerDataTools(server, { apiGet, withLabel = (t) => t, withSo
"Filtrable par statut. Ne renvoie pas les remboursements détaillés — " + "Filtrable par statut. Ne renvoie pas les remboursements détaillés — " +
"utiliser crowdlending_get_investissement pour le détail d'un projet."), "utiliser crowdlending_get_investissement pour le détail d'un projet."),
inputSchema: { inputSchema: {
statut: z.enum(['en_cours', 'rembourse', 'en_retard', 'procedure', 'cloture']) statut: z.enum(['en_cours', 'rembourse', 'en_retard', 'procedure', 'cloture', 'perte_definitive', 'prolongation'])
.optional() .optional()
.describe("Filtrer par statut. Omettre pour lister tous les investissements."), .describe("Filtrer par statut. Omettre pour lister tous les investissements."),
}, },