diff --git a/frontend/src/pages/InvestissementPeDetail.jsx b/frontend/src/pages/InvestissementPeDetail.jsx index 0fdd9ac..8ecd260 100644 --- a/frontend/src/pages/InvestissementPeDetail.jsx +++ b/frontend/src/pages/InvestissementPeDetail.jsx @@ -462,7 +462,7 @@ export default function InvestissementPeDetail() {

Remboursements

- +
diff --git a/frontend/src/pages/RemboursementsSimple.jsx b/frontend/src/pages/RemboursementsSimple.jsx index 0621f97..f81d7f2 100644 --- a/frontend/src/pages/RemboursementsSimple.jsx +++ b/frontend/src/pages/RemboursementsSimple.jsx @@ -183,6 +183,10 @@ export default function RemboursementsSimple() { const [rows, setRows] = useState([]); const [allPlats, setAllPlats] = useState([]); const [deals, setDeals] = useState([]); + // Taux PFU par année (17/09/26, cf. project ask Olivier : aligner la gestion de la + // fiscalité PE sur celle de Remboursements.jsx crowdlending) — sert au calcul + // automatique des prélèvements sociaux/forfaitaire, cf. RemboursementForm ci-dessous. + const [pfuRates, setPfuRates] = useState([]); const [loading, setLoading] = useState(true); const [err, setErr] = useState(null); @@ -215,14 +219,16 @@ export default function RemboursementsSimple() { const load = async () => { setLoading(true); try { - const [rb, pl, dl] = await Promise.all([ + const [rb, pl, dl, pfu] = await Promise.all([ api.get('/remboursements'), api.get('/plateformes'), api.get('/investissements-pe'), + api.get('/pfu'), ]); setRows(rb); setAllPlats(pl); setDeals(dl); + setPfuRates(pfu); } catch (e) { setErr(e.message); } finally { @@ -240,25 +246,42 @@ export default function RemboursementsSimple() { return plat?.investisseur_id ? String(plat.investisseur_id) : String(investisseurId || ''); }; - const openAdd = () => { + // dealId optionnel (17/09/26, cf. InvestissementPeDetail.jsx "+ Nouveau + // remboursement" — trou remonté par Olivier : ce bouton renvoyait vers le + // formulaire vierge, sans présélectionner ni la plateforme ni le deal) : + // pré-remplit la plateforme du deal, le lien investissement_pe_id, et bascule + // le type par défaut sur 'interets_plateforme' (le seul type porteur des + // champs fiscaux — cf. avis de distribution, cas typique de ce raccourci). + const openAdd = (dealId) => { setEditingId(null); - const firstId = plats[0]?.id || ''; - setForm({ ...emptyRembForm, bonus_plateforme_id: firstId, bonus_investisseur_id: investisseurForPlat(firstId) }); + const deal = dealId ? deals.find(d => String(d.id) === String(dealId)) : null; + const firstId = deal ? deal.plateforme_id : (plats[0]?.id || ''); + setForm({ + ...emptyRembForm, + ...(deal ? { type: 'interets_plateforme' } : {}), + bonus_plateforme_id: firstId, + bonus_investisseur_id: investisseurForPlat(firstId), + investissement_pe_id: deal ? String(deal.id) : '', + }); setErr(null); setModalOpen(true); }; // Ouverture auto depuis le bouton global "Nouveau remboursement" (topbar/burger, - // cf. Layout.jsx — même ?new=1 que la page crowdlending et que Dépôts/Retraits). + // cf. Layout.jsx — même ?new=1 que la page crowdlending et que Dépôts/Retraits), + // ou depuis la fiche d'un deal PE (?new=1&deal=, cf. InvestissementPeDetail.jsx). const pendingNew = useRef(false); + const pendingDealId = useRef(null); useEffect(() => { const params = new URLSearchParams(location.search); if (params.get('new') !== '1') return; + const dealId = params.get('deal'); navigate('/remboursements', { replace: true }); if (plats.length > 0) { - openAdd(); + openAdd(dealId); } else { pendingNew.current = true; + pendingDealId.current = dealId; } // eslint-disable-next-line react-hooks/exhaustive-deps }, [location.search]); @@ -266,7 +289,9 @@ export default function RemboursementsSimple() { useEffect(() => { if (pendingNew.current && plats.length > 0) { pendingNew.current = false; - openAdd(); + const dealId = pendingDealId.current; + pendingDealId.current = null; + openAdd(dealId); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [plats.length]); @@ -480,7 +505,7 @@ export default function RemboursementsSimple() { > + investisseurForPlat={investisseurForPlat} editingId={editingId} pfuRates={pfuRates} /> ); @@ -489,7 +514,7 @@ export default function RemboursementsSimple() { <>

Remboursements

- +
{err &&
{err}
} @@ -755,7 +780,7 @@ export default function RemboursementsSimple() { > + investisseurForPlat={investisseurForPlat} editingId={editingId} pfuRates={pfuRates} /> Math.round(n * 100) / 100; +const fmtRatePfu = (n) => n != null ? n.toFixed(1).replace('.', ',') + ' %' : '…'; +const getRatesForYearPe = (dateStr, pfuRates) => { + if (!dateStr || !pfuRates?.length) return null; + const year = parseInt(dateStr.slice(0, 4), 10); + return pfuRates.find(r => r.annee === year) + ?? pfuRates.reduce((best, r) => r.annee > best.annee ? r : best, pfuRates[0]); +}; + /** Formulaire d'ajout/édition — factorisé pour être identique dans les deux * points d'entrée (page avec mouvements existants, et état vide). Champs * strictement alignés sur le Schema de backend/src/routes/remboursements.js, * limités aux types bonus_parrainage / bonus_plateforme / interets_plateforme * (le type "normal", lié à un investissement, reste crowdlending uniquement). */ -function RemboursementForm({ form, setForm, plats, investisseurs, deals, multiDetenteur, err, runSubmit, submit, investisseurForPlat, editingId }) { +function RemboursementForm({ form, setForm, plats, investisseurs, deals, multiDetenteur, err, runSubmit, submit, investisseurForPlat, editingId, pfuRates }) { const isInterets = form.type === 'interets_plateforme'; // Deals PE de la plateforme sélectionnée (un remboursement bonus/intérêts @@ -784,13 +823,54 @@ function RemboursementForm({ form, setForm, plats, investisseurs, deals, multiDe // qu'un seul deal, ou aucun deal PE suivi pour l'instant. const dealsForPlat = deals.filter(d => String(d.plateforme_id) === String(form.bonus_plateforme_id)); + // ── Fiscalité (même logique que RembFormModal.jsx crowdlending) ──────── + const currentPlat = plats.find(p => String(p.id) === String(form.bonus_plateforme_id)); + const hasLocalTax = isInterets && currentPlat?.fiscalite === 'avec_fiscalite_locale' && currentPlat?.taux_fiscalite_locale; + // Pas d'équivalent fiscalite_override côté investissements_pe (pas de notion + // d'exonération par deal) : seule la fiscalité de la plateforme détermine le + // caractère indicatif, contrairement à crowdlending qui croise aussi + // investissement.fiscalite_override — cf. table investissements_pe (db/index.js). + const isIndicatif = isInterets && Boolean(currentPlat && currentPlat.fiscalite !== 'flat_tax'); + const rates = isInterets ? getRatesForYearPe(form.date_remb, pfuRates) : null; + const labelPS = `Prélèvements sociaux${rates ? ` (${fmtRatePfu(rates.prelev_sociaux)})` : ''} (€)`; + const labelIR = `Impôt sur le revenu${rates ? ` (${fmtRatePfu(rates.impot_revenu)})` : ''} (€)`; + + // Recalcule automatiquement taxe locale / prélèvements PFU quand un champ + // "moteur" change — l'utilisateur garde la main pour corriger prelev_sociaux/ + // prelev_forfaitaire ensuite (ces deux champs ne redéclenchent pas ce recalcul, + // exactement comme setField()/onFormPatch() dans Remboursements.jsx). + const setFiscalField = (key, value) => { + const next = { ...form, [key]: value }; + if (key === 'interets_bruts_avant_local') { + const taux = hasLocalTax ? (currentPlat.taux_fiscalite_locale || 0) : 0; + next.taxe_locale = round2(Number(value) * taux / 100); + next.interets_bruts = round2(Number(value) - next.taxe_locale); + } + if (key === 'taxe_locale') { + next.interets_bruts = round2(Number(next.interets_bruts_avant_local || 0) - Number(value)); + } + if (['date_remb', 'interets_bruts', 'interets_bruts_avant_local', 'taxe_locale', 'frais_ttc'].includes(key)) { + const dateStr = key === 'date_remb' ? value : next.date_remb; + const base = round2((Number(next.interets_bruts) || 0) - (Number(next.frais_ttc) || 0)); + const r = getRatesForYearPe(dateStr, pfuRates); + if (r) { + next.prelev_sociaux = round2(base * r.prelev_sociaux / 100); + next.prelev_forfaitaire = round2(base * r.impot_revenu / 100); + } + } + setForm(next); + }; + + const interetsNets = round2( + (Number(form.interets_bruts) || 0) - (Number(form.frais_ttc) || 0) + - (Number(form.prelev_sociaux) || 0) - (Number(form.prelev_forfaitaire) || 0) + ); + const previewNet = () => { if (isInterets) { - const bruts = Number(form.interets_bruts) || 0; - const frais = Number(form.frais_ttc) || 0; - const ps = Number(form.prelev_sociaux) || 0; - const pf = Number(form.prelev_forfaitaire) || 0; - return Math.round((bruts - frais - ps - pf) * 100) / 100; + return isIndicatif + ? round2((Number(form.interets_bruts) || 0) - (Number(form.frais_ttc) || 0)) + : interetsNets; } return Number(form.cashback) || 0; }; @@ -842,29 +922,61 @@ function RemboursementForm({ form, setForm, plats, investisseurs, deals, multiDe {isInterets && ( <> + {hasLocalTax && ( +
+ + setFiscalField('interets_bruts_avant_local', e.target.value)} /> +
+ )} + {hasLocalTax && ( +
+ + setFiscalField('taxe_locale', e.target.value)} /> +
+ )}
- - setForm({ ...form, interets_bruts: e.target.value })} /> -
-
- - setForm({ ...form, interets_bruts_avant_local: e.target.value })} /> -
-
- - setForm({ ...form, taxe_locale: e.target.value })} /> + + !hasLocalTax && setFiscalField('interets_bruts', e.target.value)} />
- setForm({ ...form, frais_ttc: e.target.value })} /> + setFiscalField('frais_ttc', e.target.value)} /> +
+ +
+ + Imposition{isIndicatif ? ' — indicatif' : ''} +
- - setForm({ ...form, prelev_sociaux: e.target.value })} /> + + !isIndicatif && setForm({ ...form, prelev_sociaux: e.target.value })} />
- - setForm({ ...form, prelev_forfaitaire: e.target.value })} /> + + !isIndicatif && setForm({ ...form, prelev_forfaitaire: e.target.value })} /> +
+
+ + +
+
+ {isIndicatif + ? "L'imposition n'est pas déduite du montant net perçu (fiscalité appliquée à titre indicatif — plateforme hors flat tax)." + : "L'imposition est déduite du montant net perçu (intérêts bruts − frais − prélèvements)."}
)} @@ -902,9 +1014,11 @@ function RemboursementForm({ form, setForm, plats, investisseurs, deals, multiDe setForm({ ...form, notes: e.target.value })} /> -
- Net perçu estimé : {fmtEUR(previewNet())} -
+ {!isInterets && ( +
+ Net perçu estimé : {fmtEUR(previewNet())} +
+ )} );
DateMontant verséNotes