This commit is contained in:
ocroguennec committed 2026-09-17 19:01:43 +02:00
1 parent 4b7df66862
commit 56d52d538e
2 files changed
+142 -28

No files matched your search

@@ -462,7 +462,7 @@ export default function InvestissementPeDetail() {
<div className="card" style={{ marginBottom: 16 }}> <div className="card" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<h3 style={{ margin: 0 }}>Remboursements</h3> <h3 style={{ margin: 0 }}>Remboursements</h3>
<button onClick={() => navigate('/remboursements?new=1')}>+ Nouveau remboursement</button> <button onClick={() => navigate(`/remboursements?new=1&deal=${deal.id}`)}>+ Nouveau remboursement</button>
</div> </div>
<table> <table>
<thead><tr><th>Date</th><th className="num">Montant versé</th><th>Notes</th></tr></thead> <thead><tr><th>Date</th><th className="num">Montant versé</th><th>Notes</th></tr></thead>
+141 -27
View File
@@ -183,6 +183,10 @@ export default function RemboursementsSimple() {
const [rows, setRows] = useState([]); const [rows, setRows] = useState([]);
const [allPlats, setAllPlats] = useState([]); const [allPlats, setAllPlats] = useState([]);
const [deals, setDeals] = 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 [loading, setLoading] = useState(true);
const [err, setErr] = useState(null); const [err, setErr] = useState(null);
@@ -215,14 +219,16 @@ export default function RemboursementsSimple() {
const load = async () => { const load = async () => {
setLoading(true); setLoading(true);
try { try {
const [rb, pl, dl] = await Promise.all([ const [rb, pl, dl, pfu] = await Promise.all([
api.get('/remboursements'), api.get('/remboursements'),
api.get('/plateformes'), api.get('/plateformes'),
api.get('/investissements-pe'), api.get('/investissements-pe'),
api.get('/pfu'),
]); ]);
setRows(rb); setRows(rb);
setAllPlats(pl); setAllPlats(pl);
setDeals(dl); setDeals(dl);
setPfuRates(pfu);
} catch (e) { } catch (e) {
setErr(e.message); setErr(e.message);
} finally { } finally {
@@ -240,25 +246,42 @@ export default function RemboursementsSimple() {
return plat?.investisseur_id ? String(plat.investisseur_id) : String(investisseurId || ''); 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); setEditingId(null);
const firstId = plats[0]?.id || ''; const deal = dealId ? deals.find(d => String(d.id) === String(dealId)) : null;
setForm({ ...emptyRembForm, bonus_plateforme_id: firstId, bonus_investisseur_id: investisseurForPlat(firstId) }); 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); setErr(null);
setModalOpen(true); setModalOpen(true);
}; };
// Ouverture auto depuis le bouton global "Nouveau remboursement" (topbar/burger, // 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=<id>, cf. InvestissementPeDetail.jsx).
const pendingNew = useRef(false); const pendingNew = useRef(false);
const pendingDealId = useRef(null);
useEffect(() => { useEffect(() => {
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
if (params.get('new') !== '1') return; if (params.get('new') !== '1') return;
const dealId = params.get('deal');
navigate('/remboursements', { replace: true }); navigate('/remboursements', { replace: true });
if (plats.length > 0) { if (plats.length > 0) {
openAdd(); openAdd(dealId);
} else { } else {
pendingNew.current = true; pendingNew.current = true;
pendingDealId.current = dealId;
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.search]); }, [location.search]);
@@ -266,7 +289,9 @@ export default function RemboursementsSimple() {
useEffect(() => { useEffect(() => {
if (pendingNew.current && plats.length > 0) { if (pendingNew.current && plats.length > 0) {
pendingNew.current = false; pendingNew.current = false;
openAdd(); const dealId = pendingDealId.current;
pendingDealId.current = null;
openAdd(dealId);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [plats.length]); }, [plats.length]);
@@ -480,7 +505,7 @@ export default function RemboursementsSimple() {
> >
<RemboursementForm form={form} setForm={setForm} plats={plats} investisseurs={investisseurs} deals={deals} <RemboursementForm form={form} setForm={setForm} plats={plats} investisseurs={investisseurs} deals={deals}
multiDetenteur={multiDetenteur} err={err} runSubmit={runSubmit} submit={submit} multiDetenteur={multiDetenteur} err={err} runSubmit={runSubmit} submit={submit}
investisseurForPlat={investisseurForPlat} editingId={editingId} /> investisseurForPlat={investisseurForPlat} editingId={editingId} pfuRates={pfuRates} />
</Modal> </Modal>
</> </>
); );
@@ -489,7 +514,7 @@ export default function RemboursementsSimple() {
<> <>
<div className="topbar"> <div className="topbar">
<h2><PageIcon name="remboursement" />Remboursements</h2> <h2><PageIcon name="remboursement" />Remboursements</h2>
<button className="primary" onClick={openAdd}>+ Nouveau remboursement</button> <button className="primary" onClick={() => openAdd()}>+ Nouveau remboursement</button>
</div> </div>
{err && <div className="error" style={{ margin: '0 0 16px' }}>{err}</div>} {err && <div className="error" style={{ margin: '0 0 16px' }}>{err}</div>}
@@ -755,7 +780,7 @@ export default function RemboursementsSimple() {
> >
<RemboursementForm form={form} setForm={setForm} plats={plats} investisseurs={investisseurs} deals={deals} <RemboursementForm form={form} setForm={setForm} plats={plats} investisseurs={investisseurs} deals={deals}
multiDetenteur={multiDetenteur} err={err} runSubmit={runSubmit} submit={submit} multiDetenteur={multiDetenteur} err={err} runSubmit={runSubmit} submit={submit}
investisseurForPlat={investisseurForPlat} editingId={editingId} /> investisseurForPlat={investisseurForPlat} editingId={editingId} pfuRates={pfuRates} />
</Modal> </Modal>
<ConfirmModal <ConfirmModal
@@ -770,12 +795,26 @@ export default function RemboursementsSimple() {
); );
} }
/* ── Fiscalité (17/09/26, cf. Remboursements.jsx / RembFormModal.jsx crowdlending,
* à la demande d'Olivier : la saisie d'une distribution PE doit s'appuyer sur les
* mêmes taux PFU et la même logique indicatif/retenue-à-la-source, pas sur des champs
* prélèvements en saisie libre). Helpers purs, dupliqués volontairement (pas d'état) —
* même formules que RembFormModal.jsx. ── */
const round2 = (n) => 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 /** Formulaire d'ajout/édition — factorisé pour être identique dans les deux
* points d'entrée (page avec mouvements existants, et état vide). Champs * points d'entrée (page avec mouvements existants, et état vide). Champs
* strictement alignés sur le Schema de backend/src/routes/remboursements.js, * strictement alignés sur le Schema de backend/src/routes/remboursements.js,
* limités aux types bonus_parrainage / bonus_plateforme / interets_plateforme * limités aux types bonus_parrainage / bonus_plateforme / interets_plateforme
* (le type "normal", lié à un investissement, reste crowdlending uniquement). */ * (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'; const isInterets = form.type === 'interets_plateforme';
// Deals PE de la plateforme sélectionnée (un remboursement bonus/intérêts // 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. // 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)); 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 = () => { const previewNet = () => {
if (isInterets) { if (isInterets) {
const bruts = Number(form.interets_bruts) || 0; return isIndicatif
const frais = Number(form.frais_ttc) || 0; ? round2((Number(form.interets_bruts) || 0) - (Number(form.frais_ttc) || 0))
const ps = Number(form.prelev_sociaux) || 0; : interetsNets;
const pf = Number(form.prelev_forfaitaire) || 0;
return Math.round((bruts - frais - ps - pf) * 100) / 100;
} }
return Number(form.cashback) || 0; return Number(form.cashback) || 0;
}; };
@@ -842,29 +922,61 @@ function RemboursementForm({ form, setForm, plats, investisseurs, deals, multiDe
{isInterets && ( {isInterets && (
<> <>
{hasLocalTax && (
<div> <div>
<label>Intérêts bruts (€) *</label> <label>Intérêts bruts avant retenue locale ({currentPlat.taux_fiscalite_locale} %) (€)</label>
<input type="number" step="0.01" min="0" required value={form.interets_bruts} onChange={e => setForm({ ...form, interets_bruts: e.target.value })} /> <input type="number" step="0.01" min="0" value={form.interets_bruts_avant_local}
onChange={e => setFiscalField('interets_bruts_avant_local', e.target.value)} />
</div> </div>
)}
{hasLocalTax && (
<div> <div>
<label>Intérêts bruts avant taxe locale (€)</label> <label>Retenue à la source locale (€)</label>
<input type="number" step="0.01" min="0" value={form.interets_bruts_avant_local} onChange={e => setForm({ ...form, interets_bruts_avant_local: e.target.value })} /> <input type="number" step="0.01" min="0" value={form.taxe_locale}
onChange={e => setFiscalField('taxe_locale', e.target.value)} />
</div> </div>
)}
<div> <div>
<label>Taxe locale (€)</label> <label>{hasLocalTax ? 'Intérêts bruts après retenue locale (€)' : 'Intérêts bruts (€) *'}</label>
<input type="number" step="0.01" min="0" value={form.taxe_locale} onChange={e => setForm({ ...form, taxe_locale: e.target.value })} /> <input type="number" step="0.01" min="0" required={!hasLocalTax} value={form.interets_bruts}
readOnly={!!hasLocalTax}
style={hasLocalTax ? { background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'not-allowed' } : undefined}
onChange={e => !hasLocalTax && setFiscalField('interets_bruts', e.target.value)} />
</div> </div>
<div> <div>
<label>Frais TTC (€)</label> <label>Frais TTC (€)</label>
<input type="number" step="0.01" min="0" value={form.frais_ttc} onChange={e => setForm({ ...form, frais_ttc: e.target.value })} /> <input type="number" step="0.01" min="0" value={form.frais_ttc}
onChange={e => setFiscalField('frais_ttc', e.target.value)} />
</div>
<div style={{ gridColumn: '1 / -1', borderTop: '1px solid var(--border)', paddingTop: 4, marginTop: 4 }}>
<span style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--text-muted)' }}>
Imposition{isIndicatif ? ' — indicatif' : ''}
</span>
</div> </div>
<div> <div>
<label>Prélèvements sociaux (€)</label> <label>{labelPS}</label>
<input type="number" step="0.01" min="0" value={form.prelev_sociaux} onChange={e => setForm({ ...form, prelev_sociaux: e.target.value })} /> <input type="number" step="0.01" value={form.prelev_sociaux}
readOnly={isIndicatif}
style={isIndicatif ? { background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'not-allowed' } : undefined}
onChange={e => !isIndicatif && setForm({ ...form, prelev_sociaux: e.target.value })} />
</div> </div>
<div> <div>
<label>Prélèvement forfaitaire (€)</label> <label>{labelIR}</label>
<input type="number" step="0.01" min="0" value={form.prelev_forfaitaire} onChange={e => setForm({ ...form, prelev_forfaitaire: e.target.value })} /> <input type="number" step="0.01" value={form.prelev_forfaitaire}
readOnly={isIndicatif}
style={isIndicatif ? { background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'not-allowed' } : undefined}
onChange={e => !isIndicatif && setForm({ ...form, prelev_forfaitaire: e.target.value })} />
</div>
<div>
<label>Montant net perçu (€)</label>
<input type="number" step="0.01" readOnly value={previewNet()}
style={{ background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'not-allowed' }} />
</div>
<div style={{ gridColumn: '1 / -1', color: 'var(--text-muted, #6b7280)', fontSize: 12 }}>
{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)."}
</div> </div>
</> </>
)} )}
@@ -902,9 +1014,11 @@ function RemboursementForm({ form, setForm, plats, investisseurs, deals, multiDe
<label>Notes</label> <label>Notes</label>
<input value={form.notes} onChange={e => setForm({ ...form, notes: e.target.value })} /> <input value={form.notes} onChange={e => setForm({ ...form, notes: e.target.value })} />
</div> </div>
{!isInterets && (
<div style={{ gridColumn: '1 / -1', color: 'var(--text-muted, #6b7280)', fontSize: 13 }}> <div style={{ gridColumn: '1 / -1', color: 'var(--text-muted, #6b7280)', fontSize: 13 }}>
Net perçu estimé : {fmtEUR(previewNet())} Net perçu estimé : {fmtEUR(previewNet())}
</div> </div>
)}
</div> </div>
</form> </form>
); );