Mise en place de la feature de gestion des Frais

This commit is contained in:
ocroguennec committed 2026-09-01 22:16:18 +02:00
1 parent 9ea078302d
commit 0334a63139
27 files changed
+2401 -117

No files matched your search

+93 -2
View File
@@ -239,6 +239,11 @@ const empty = {
statut: 'en_cours', reference: '', notes: '',
echeance_fin_de_mois: false, methode_remboursement: '', nom_compte_courant: '', compte_id: '', pays_exposition: 'FR',
categories_inv_ids: [], secteurs_inv_ids: [],
// Frais liés à la plateforme (étape conditionnelle, visible création ET modification —
// cf. project_frais_operation.md). frais_id : id de l'entrée frais_operations de catégorie
// 'souscription' déjà existante pour cet investissement (pré-remplie par openEdit), null si
// aucune — pilote l'upsert (POST/PUT/DELETE) au submit, jamais affiché dans le formulaire.
frais_mode_saisie: 'montant', frais_valeur: '', frais_mode_reglement: 'source', frais_compte_id: '', frais_id: null,
};
/* ── Composant principal ─────────────────────────────────────── */
@@ -702,9 +707,31 @@ export default function Investissements() {
pays_exposition: row.pays_exposition || 'FR',
categories_inv_ids: (row.categories_inv || []).map(c => c.id),
secteurs_inv_ids: (row.secteurs_inv || []).map(s => s.id),
frais_mode_saisie: 'montant', frais_valeur: '', frais_mode_reglement: 'source', frais_compte_id: '', frais_id: null,
});
setErr(null);
setModalOpen(true);
// Pré-remplissage de l'étape "Frais" avec le frais de souscription déjà documenté pour cet
// investissement, s'il existe (catégorie 'souscription', unique par investissement — cf.
// project_frais_operation.md). Chargé après l'ouverture de la modale pour ne pas bloquer
// l'affichage ; mise à jour du form dès la réponse.
try {
const fraisOps = await api.get('/frais-operations', { investissement_id: row.id });
const souscriptionFrais = (fraisOps || []).find(f => f.categorie === 'souscription');
if (souscriptionFrais) {
setForm(f => ({
...f,
frais_mode_saisie: 'montant',
frais_valeur: souscriptionFrais.montant,
frais_mode_reglement: souscriptionFrais.mode_reglement,
frais_compte_id: souscriptionFrais.compte_id || '',
frais_id: souscriptionFrais.id,
}));
}
} catch {
// Pré-remplissage best-effort — une erreur réseau ne doit pas empêcher l'édition.
}
};
/** Changement de plateforme dans le formulaire : résout la méthode de remboursement,
@@ -724,10 +751,12 @@ export default function Investissements() {
methode_remboursement: newMethode, compte_id: '',
categories_inv_ids: platCats.map(c => c.id),
secteurs_inv_ids: platSects.map(s => s.id),
frais_mode_saisie: 'montant', frais_valeur: '', frais_mode_reglement: 'source', frais_compte_id: '',
}));
});
} else {
setForm(f => ({ ...f, plateforme_id: platId, investisseur_id: investisseurForPlat(platId), methode_remboursement: newMethode, compte_id: '', categories_inv_ids: [], secteurs_inv_ids: [] }));
setForm(f => ({ ...f, plateforme_id: platId, investisseur_id: investisseurForPlat(platId), methode_remboursement: newMethode, compte_id: '', categories_inv_ids: [], secteurs_inv_ids: [],
frais_mode_saisie: 'montant', frais_valeur: '', frais_mode_reglement: 'source', frais_compte_id: '' }));
}
};
@@ -751,6 +780,26 @@ export default function Investissements() {
return;
}
// Frais liés à la plateforme (étape conditionnelle, création ET modification — le bloc
// "Frais d'opération" de la fiche investissement reste le lieu pour les frais de
// catégorie gestion/distribution/autre, cf. project_frais_operation.md) : à la création,
// si prélevés "à la source", le capital net (montant investi diminué des frais) est ce
// qui est enregistré comme montant_investi ; en modification, montant_investi n'est
// JAMAIS réajusté automatiquement (demande explicite d'Olivier, 31/08/26) — c'est à
// l'utilisateur de l'ajuster lui-même à l'étape 1 si besoin. Dans les deux cas, le frais
// est journalisé (POST/PUT/DELETE selon form.frais_id) après l'enregistrement principal.
const platFraisType = plats.find(p => String(p.id) === String(form.plateforme_id))?.frais_plateforme;
const hasFrais = platFraisType && platFraisType !== 'aucun'
&& form.frais_valeur !== '' && Number(form.frais_valeur) > 0;
const fraisTTC = hasFrais
? Math.round((form.frais_mode_saisie === 'pourcentage'
? Number(form.montant_investi) * Number(form.frais_valeur) / 100
: Number(form.frais_valeur)) * 100) / 100
: 0;
const montantInvestiFinal = !editingId && hasFrais && form.frais_mode_reglement === 'source'
? Math.max(0, Math.round((Number(form.montant_investi) - fraisTTC) * 100) / 100)
: Number(form.montant_investi);
const payload = {
investisseur_id: Number(form.investisseur_id) || undefined,
plateforme_id: Number(form.plateforme_id),
@@ -759,7 +808,7 @@ export default function Investissements() {
date_souscription: form.date_souscription,
date_premiere_echeance: form.date_premiere_echeance || '',
date_cible: form.date_cible || '',
montant_investi: Number(form.montant_investi),
montant_investi: montantInvestiFinal,
taux_interet: form.taux_interet === '' ? undefined : Number(form.taux_interet),
duree_mois: form.duree_mois === '' ? undefined : Number(form.duree_mois),
type_remb: form.type_remb || '',
@@ -779,6 +828,31 @@ export default function Investissements() {
api.put(`/investissements/${editingId}/categories-inv`, { ids: form.categories_inv_ids || [] }),
api.put(`/investissements/${editingId}/secteurs-inv`, { ids: form.secteurs_inv_ids || [] }),
]);
// Upsert du frais de souscription (catégorie unique par investissement) : PUT si déjà
// chargé via form.frais_id (pré-rempli par openEdit), POST sinon ; DELETE si le montant
// a été vidé alors qu'une entrée existait — best-effort, n'empêche jamais l'édition
// principale de l'investissement.
try {
if (hasFrais) {
const fraisPayload = {
investissement_id: editingId,
montant: fraisTTC,
mode_reglement: form.frais_mode_reglement,
compte_id: form.frais_mode_reglement === 'compte_courant' && form.frais_compte_id ? Number(form.frais_compte_id) : null,
date_operation: form.date_souscription,
origine: 'souscription',
categorie: 'souscription',
};
if (form.frais_id) await api.put(`/frais-operations/${form.frais_id}`, fraisPayload);
else await api.post('/frais-operations', fraisPayload);
} else if (form.frais_id) {
await api.del(`/frais-operations/${form.frais_id}`);
}
} catch {
// Non bloquant — rattrapable a posteriori depuis le bloc "Frais d'opération".
}
close(); await load();
} else {
const created = await api.post('/investissements', payload);
@@ -787,6 +861,23 @@ export default function Investissements() {
api.put(`/investissements/${created.id}/secteurs-inv`, { ids: form.secteurs_inv_ids || [] }),
]);
if (hasFrais) {
try {
await api.post('/frais-operations', {
investissement_id: created.id,
montant: fraisTTC,
mode_reglement: form.frais_mode_reglement,
compte_id: form.frais_mode_reglement === 'compte_courant' && form.frais_compte_id ? Number(form.frais_compte_id) : null,
date_operation: form.date_souscription,
origine: 'souscription',
categorie: 'souscription',
});
} catch {
// Frais non journalisés (erreur réseau…) — n'empêche pas la création de l'investissement,
// qui a déjà pris en compte le capital net le cas échéant. Rattrapable a posteriori.
}
}
// Contrôle du porte-monnaie — création uniquement (jamais en édition). Le solde
// recalculé APRÈS création tient déjà compte du capital de ce nouvel investissement
// (formule officielle, cf. mémoire project_solde_portefeuille) : s'il ressort négatif,