diff --git a/backend/src/db/index.js b/backend/src/db/index.js
index 6025f37..59d9bde 100644
--- a/backend/src/db/index.js
+++ b/backend/src/db/index.js
@@ -2301,4 +2301,43 @@ db.exec('CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash)');
}
}
+// ── Migration : v_interets_annuels — inclure les "Intérêts plateforme" ──
+// Nouveau type de remboursement (intérêts versés par une plateforme, hors
+// investissement précis, rattachés via bonus_plateforme_id/bonus_investisseur_id
+// comme les bonus). Contrairement aux bonus (cashback non imposable), ce sont
+// de vrais intérêts fiscalisés : ils doivent compter dans le récap annuel au
+// même titre que les intérêts d'investissement classiques.
+{
+ const viewRow = db.prepare(
+ "SELECT sql FROM sqlite_master WHERE type='view' AND name='v_interets_annuels'"
+ ).get();
+ if (viewRow && !viewRow.sql.includes('interets_plateforme')) {
+ db.exec('DROP VIEW IF EXISTS v_interets_annuels');
+ db.exec(`
+ CREATE VIEW v_interets_annuels AS
+ SELECT investisseur_id, annee,
+ SUM(interets_bruts) AS interets_bruts,
+ SUM(prelev_sociaux) AS prelev_sociaux,
+ SUM(prelev_forfaitaire) AS prelev_forfaitaire,
+ SUM(net_recu) AS net_recu
+ FROM (
+ SELECT i.investisseur_id AS investisseur_id,
+ substr(r.date_remb,1,4) AS annee,
+ r.interets_bruts, r.prelev_sociaux, r.prelev_forfaitaire, r.net_recu
+ FROM remboursements r
+ JOIN investissements i ON i.id = r.investissement_id
+ WHERE r.statut IN ('paye','partiel') AND r.type = 'normal'
+ UNION ALL
+ SELECT r.bonus_investisseur_id AS investisseur_id,
+ substr(r.date_remb,1,4) AS annee,
+ r.interets_bruts, r.prelev_sociaux, r.prelev_forfaitaire, r.net_recu
+ FROM remboursements r
+ WHERE r.statut IN ('paye','partiel') AND r.type = 'interets_plateforme'
+ )
+ GROUP BY investisseur_id, annee
+ `);
+ console.log('[DB] v_interets_annuels: vue recréée (inclut interets_plateforme)');
+ }
+}
+
export default db;
diff --git a/backend/src/routes/dashboard.js b/backend/src/routes/dashboard.js
index 097367a..349966a 100644
--- a/backend/src/routes/dashboard.js
+++ b/backend/src/routes/dashboard.js
@@ -92,6 +92,17 @@ router.get('/', (req, res) => {
GROUP BY bonus_plateforme_id
`).all(...invParams);
+ // Intérêts plateforme : comme les bonus, ils créditent directement le
+ // porte-monnaie de la plateforme (net_recu tient déjà compte des prélèvements).
+ const interetsPlateformeWalletPerPlat = db.prepare(`
+ SELECT bonus_plateforme_id AS plateforme_id,
+ COALESCE(SUM(net_recu), 0) AS interets_plateforme_wallet
+ FROM remboursements
+ WHERE ${invWhere.replace('investisseur_id', 'bonus_investisseur_id')}
+ AND type = 'interets_plateforme'
+ GROUP BY bonus_plateforme_id
+ `).all(...invParams);
+
const capitalInvestiPerPlat = db.prepare(`
SELECT i.plateforme_id,
COALESCE(SUM(
@@ -117,6 +128,7 @@ router.get('/', (req, res) => {
for (const r of drManuelPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) + r.depots - r.retraits_manuels;
for (const r of rembWalletPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) + r.remb_wallet;
for (const r of bonusWalletPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) + r.bonus_wallet;
+ for (const r of interetsPlateformeWalletPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) + r.interets_plateforme_wallet;
for (const r of capitalInvestiPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) - r.capital_investi;
for (const r of correctionsPerPlat) walletMap[r.plateforme_id] = (walletMap[r.plateforme_id] || 0) + r.total_correction;
@@ -149,6 +161,10 @@ router.get('/', (req, res) => {
`).get(...invParams);
/* ── Interets ────────────────────────────────────────────────── */
+ // Inclut les remboursements normaux (liés à un investissement) et les
+ // "Intérêts plateforme" (type=interets_plateforme, rattachés via
+ // bonus_plateforme_id/bonus_investisseur_id comme les bonus, mais ce sont
+ // de vrais intérêts fiscalisés — contrairement aux bonus qui restent exclus).
const interets = db.prepare(`
SELECT
COALESCE(SUM(r.interets_bruts),0) AS interets_bruts,
@@ -156,9 +172,12 @@ router.get('/', (req, res) => {
COALESCE(SUM(r.prelev_forfaitaire),0) AS prelev_forfaitaire,
COALESCE(SUM(r.net_recu),0) AS net_recu
FROM remboursements r
- JOIN investissements i ON i.id = r.investissement_id
- WHERE ${invWhere.replace('investisseur_id', 'i.investisseur_id')}
- `).get(...invParams);
+ LEFT JOIN investissements i ON i.id = r.investissement_id
+ WHERE (
+ (r.investissement_id IS NOT NULL AND ${invWhere.replace('investisseur_id', 'i.investisseur_id')})
+ OR (r.investissement_id IS NULL AND r.type = 'interets_plateforme' AND ${invWhere.replace('investisseur_id', 'r.bonus_investisseur_id')})
+ )
+ `).get(...invParams, ...invParams);
/* ── Interets par annee ──────────────────────────────────────── */
const interetsParAnnee = scopeAll
@@ -618,6 +637,23 @@ router.get('/interets-par-plateforme', (req, res) => {
ORDER BY p.nom, mois
`).all(...invParams, anneeStr);
+ /* ── Intérêts plateforme (hors investissement) par plateforme et par mois ── */
+ const interetsPlateformeByPlat = db.prepare(`
+ SELECT
+ r.bonus_plateforme_id AS plateforme_id,
+ p.nom AS plateforme_nom,
+ strftime('%Y-%m', r.date_remb) AS mois,
+ COALESCE(SUM(r.interets_bruts), 0) AS interets_bruts,
+ COALESCE(SUM(r.interets_bruts - r.prelev_sociaux - r.prelev_forfaitaire), 0) AS interets_nets
+ FROM remboursements r
+ JOIN plateformes p ON p.id = r.bonus_plateforme_id
+ WHERE ${invWhere.replace('investisseur_id', 'bonus_investisseur_id')}
+ AND r.type = 'interets_plateforme'
+ AND strftime('%Y', r.date_remb) = ?
+ GROUP BY r.bonus_plateforme_id, p.nom, mois
+ ORDER BY p.nom, mois
+ `).all(...invParams, anneeStr);
+
/* ── Projections par plateforme et par mois ──────────────────── */
// Exclure les investissements qui ont déjà un remboursement réel dans le même mois
const projectionsByPlat = db.prepare(`
@@ -782,6 +818,17 @@ router.get('/interets-par-plateforme', (req, res) => {
rembMap[b.plateforme_id].rembourses[b.mois] = { interets_bruts: 0, interets_nets: 0, cashback: b.cashback, capital: 0 };
}
}
+ // Ajouter les intérêts plateforme (hors investissement) aux remboursements par mois
+ for (const ip of interetsPlateformeByPlat) {
+ if (!rembMap[ip.plateforme_id]) rembMap[ip.plateforme_id] = { id: ip.plateforme_id, nom: ip.plateforme_nom, detenteur_nom: detenteurMap[ip.plateforme_id] ?? null, rembourses: {}, projections: {} };
+ const entry = rembMap[ip.plateforme_id].rembourses[ip.mois];
+ if (entry) {
+ entry.interets_bruts = (entry.interets_bruts ?? 0) + ip.interets_bruts;
+ entry.interets_nets = (entry.interets_nets ?? 0) + ip.interets_nets;
+ } else {
+ rembMap[ip.plateforme_id].rembourses[ip.mois] = { interets_bruts: ip.interets_bruts, interets_nets: ip.interets_nets, cashback: 0, capital: 0 };
+ }
+ }
for (const p of projectionsByPlat) {
if (!rembMap[p.plateforme_id]) rembMap[p.plateforme_id] = { id: p.plateforme_id, nom: p.plateforme_nom, detenteur_nom: detenteurMap[p.plateforme_id] ?? null, rembourses: {}, projections: {} };
diff --git a/backend/src/routes/remboursements.js b/backend/src/routes/remboursements.js
index 6e61bd8..0ac376a 100644
--- a/backend/src/routes/remboursements.js
+++ b/backend/src/routes/remboursements.js
@@ -8,7 +8,7 @@ import { adjustSimulForActuals, generateSimulWithReinvestissements } from '../ut
const router = Router();
const Schema = z.object({
- type: z.enum(['normal', 'bonus_parrainage', 'bonus_plateforme']).default('normal'),
+ type: z.enum(['normal', 'bonus_parrainage', 'bonus_plateforme', 'interets_plateforme']).default('normal'),
investissement_id: z.number().int().positive().nullable().optional(),
bonus_plateforme_id: z.number().int().positive().nullable().optional(),
bonus_investisseur_id: z.number().int().positive().nullable().optional(),
@@ -44,13 +44,20 @@ function computeChamps(body, isTaxIndicatif = false) {
return { interets_nets, net_recu };
}
-function getTaxIndicatif(investissementId) {
- if (!investissementId) return false;
+function getTaxIndicatif(body) {
+ // Intérêts plateforme : pas d'investissement, la fiscalité applicable est celle
+ // de la plateforme associée (bonus_plateforme_id) — pas de fiscalite_override possible.
+ if (body.type === 'interets_plateforme') {
+ if (!body.bonus_plateforme_id) return false;
+ const p = db.prepare('SELECT fiscalite FROM plateformes WHERE id = ?').get(body.bonus_plateforme_id);
+ return p ? p.fiscalite !== 'flat_tax' : false;
+ }
+ if (!body.investissement_id) return false;
const row = db.prepare(`
SELECT p.fiscalite, i.fiscalite_override
FROM investissements i JOIN plateformes p ON p.id = i.plateforme_id
WHERE i.id = ?
- `).get(investissementId);
+ `).get(body.investissement_id);
if (!row) return false;
return row.fiscalite !== 'flat_tax' || row.fiscalite_override === 'exonere';
}
@@ -167,6 +174,7 @@ router.get('/', (req, res) => {
WHEN 'normal' THEN b.inv_nom_projet
WHEN 'bonus_parrainage' THEN '— Bonus Parrainage' || CASE WHEN NULLIF(b.notes, '') IS NOT NULL THEN ' — ' || b.notes ELSE '' END
WHEN 'bonus_plateforme' THEN '— Bonus Plateforme' || CASE WHEN NULLIF(b.notes, '') IS NOT NULL THEN ' — ' || b.notes ELSE '' END
+ WHEN 'interets_plateforme' THEN '— Intérêts plateforme' || CASE WHEN NULLIF(b.notes, '') IS NOT NULL THEN ' — ' || b.notes ELSE '' END
ELSE b.inv_nom_projet
END AS nom_projet,
p.nom AS plateforme_nom,
@@ -188,7 +196,7 @@ router.get('/', (req, res) => {
router.post('/', (req, res, next) => {
try {
const body = Schema.parse(req.body);
- const { interets_nets, net_recu } = computeChamps(body, getTaxIndicatif(body.investissement_id));
+ const { interets_nets, net_recu } = computeChamps(body, getTaxIndicatif(body));
if (body.type === 'normal') {
assertOwnedInvestissement(body.investissement_id, req.user.id);
@@ -229,9 +237,26 @@ router.post('/', (req, res, next) => {
return res.status(201).json({ id: r.lastInsertRowid, ...body, interets_nets, net_recu });
}
- // Bonus (parrainage ou plateforme)
+ // Bonus (parrainage / plateforme) ou Intérêts plateforme
const bonusInvestisseurId = body.bonus_investisseur_id ?? req.investisseur.id;
assertOwnedInvestisseur(bonusInvestisseurId, req.user.id);
+
+ if (body.type === 'interets_plateforme') {
+ const r = db.prepare(`
+ INSERT INTO remboursements
+ (type, bonus_plateforme_id, bonus_investisseur_id, date_remb,
+ capital, cashback, interets_bruts_avant_local, taxe_locale, interets_bruts,
+ prelev_sociaux, prelev_forfaitaire, interets_nets, net_recu, statut, source, notes)
+ VALUES ('interets_plateforme',?,?,?, 0,0,?,?,?,?,?, ?,?, ?, 'manuel', ?)
+ `).run(
+ body.bonus_plateforme_id, bonusInvestisseurId, body.date_remb,
+ body.interets_bruts_avant_local, body.taxe_locale, body.interets_bruts,
+ body.prelev_sociaux, body.prelev_forfaitaire,
+ interets_nets, net_recu, body.statut, body.notes || null,
+ );
+ return res.status(201).json({ id: r.lastInsertRowid, ...body, interets_nets, net_recu });
+ }
+
const r = db.prepare(`
INSERT INTO remboursements
(type, bonus_plateforme_id, bonus_investisseur_id, date_remb,
@@ -348,7 +373,7 @@ router.put('/:id', (req, res, next) => {
if (!existing) throw new HttpError(404, 'Not found');
const body = Schema.parse(req.body);
- const { interets_nets, net_recu } = computeChamps(body, getTaxIndicatif(body.investissement_id));
+ const { interets_nets, net_recu } = computeChamps(body, getTaxIndicatif(body));
if (body.type === 'normal') {
db.prepare(`
@@ -369,6 +394,17 @@ router.put('/:id', (req, res, next) => {
syncInvestissementStatut(body.investissement_id);
adjustSimulForActuals(db, body.investissement_id);
syncAutoRetrait(id, body.methode_remboursement, net_recu, body.investissement_id, body.date_remb);
+ } else if (body.type === 'interets_plateforme') {
+ db.prepare(`
+ UPDATE remboursements
+ SET date_remb=?, interets_bruts_avant_local=?, taxe_locale=?, interets_bruts=?,
+ prelev_sociaux=?, prelev_forfaitaire=?, interets_nets=?, net_recu=?, statut=?, notes=?
+ WHERE id=?
+ `).run(
+ body.date_remb, body.interets_bruts_avant_local, body.taxe_locale, body.interets_bruts,
+ body.prelev_sociaux, body.prelev_forfaitaire,
+ interets_nets, net_recu, body.statut, body.notes || null, id,
+ );
} else {
const bonusInvestisseurId = body.bonus_investisseur_id ?? existing.bonus_investisseur_id;
db.prepare(`
diff --git a/frontend/src/components/InvestissementSelect.jsx b/frontend/src/components/InvestissementSelect.jsx
index ad84f97..9a29405 100644
--- a/frontend/src/components/InvestissementSelect.jsx
+++ b/frontend/src/components/InvestissementSelect.jsx
@@ -15,7 +15,7 @@ import { fmtEUR } from '../utils/format.js';
* en dessous, et se ferme au scroll/resize (repositionnement non géré en direct, comme InvSelect).
*
* Props :
- * value : '' | 'BONUS_PARRAINAGE' | 'BONUS_PLATEFORME' | id (string/number)
+ * value : '' | 'BONUS_PARRAINAGE' | 'BONUS_PLATEFORME' | 'INTERETS_PLATEFORME' | id (string/number)
* onChange : (value) => void
* options : investissementOptions — { id, plateforme_nom, nom_projet, montant_investi }[]
*/
@@ -29,6 +29,7 @@ export default function InvestissementSelect({ value, onChange, options = [] })
const selectedOption = options.find(i => String(i.id) === String(value));
const label = value === 'BONUS_PARRAINAGE' ? '🎁 Bonus Parrainage'
: value === 'BONUS_PLATEFORME' ? '🏆 Bonus Plateforme'
+ : value === 'INTERETS_PLATEFORME' ? '🏦 Intérêts plateforme'
: selectedOption ? `${selectedOption.plateforme_nom} — ${selectedOption.nom_projet} (${fmtEUR(selectedOption.montant_investi)})`
: null;
@@ -123,6 +124,7 @@ export default function InvestissementSelect({ value, onChange, options = [] })
+
{options.map(i => (