Amélioration de l'API

This commit is contained in:
ocroguennec committed 2026-08-22 12:28:48 +02:00
1 parent 5dc6f21e61
commit 4ad6ea79e8
8 files changed
+387 -47

No files matched your search

+53 -2
View File
@@ -3,6 +3,8 @@ import db from '../../db/index.js';
const router = Router();
const round2 = v => Math.round((v ?? 0) * 100) / 100;
/**
* @openapi
* /dashboard:
@@ -13,7 +15,12 @@ const router = Router();
* aujourd'hui), pas des cumuls par période — un investissement remboursé
* partiellement reste compté pour son capital restant dû tant qu'il
* n'est pas soldé. Seuls les champs `interets.*` peuvent être filtrés
* par année via `?annee=`.
* par année via `?annee=`. `gain_net_depuis_debut` est TOUJOURS un cumul
* total (indépendant de `?annee=`) : intérêts nets + cashback/bonus de
* parrainage ou de plateforme + corrections de solde, sur tout
* l'historique du portefeuille — reste valable même après un retrait
* complet des plateformes, contrairement à une approche basée sur le
* solde courant.
* tags: [Dashboard]
* security: [{ ApiKeyAuth: [] }]
* parameters:
@@ -84,7 +91,51 @@ router.get('/', (req, res) => {
FROM depots_retraits WHERE ${invCond('investisseur_id')}
`).get(invParam);
res.json({ investissements, interets: { ...interets, annee: annee || null }, cash });
// ── Gain net depuis le début — même formule que le KPI du Dashboard interne :
// Σ(intérêts nets, tout l'historique) + Σ(cashback + bonus parrainage/plateforme,
// tout l'historique) + Σ(corrections de solde, tout l'historique). Volontairement
// TOUJOURS un cumul total, jamais filtré par `annee` (cf. description ci-dessus) —
// c'est ce qui le rend robuste à un retrait complet des plateformes, contrairement
// à une approche par solde. La clause OR ci-dessous inclut aussi bien les
// remboursements liés à un investissement que les "intérêts plateforme"/bonus de
// parrainage rattachés via bonus_investisseur_id (sans investissement), en miroir
// exact de GET /api/dashboard/interets-annuels.
const gainNetCond = req.investisseurScopeAll
? '(r.investissement_id IS NOT NULL AND own.user_id = ?) OR (r.bonus_investisseur_id IS NOT NULL AND bonus_own.user_id = ?)'
: '(r.investissement_id IS NOT NULL AND inv.investisseur_id = ?) OR (r.bonus_investisseur_id IS NOT NULL AND r.bonus_investisseur_id = ?)';
const gainNetRow = db.prepare(`
SELECT
COALESCE(SUM(r.interets_bruts - r.prelev_sociaux - r.prelev_forfaitaire), 0) AS interets_nets_total,
COALESCE(SUM(r.cashback), 0) AS cashback_total
FROM remboursements r
LEFT JOIN investissements inv ON inv.id = r.investissement_id
LEFT JOIN investisseurs own ON own.id = inv.investisseur_id
LEFT JOIN investisseurs bonus_own ON bonus_own.id = r.bonus_investisseur_id
WHERE ${gainNetCond}
`).get(invParam, invParam);
const correctionsRow = db.prepare(`
SELECT COALESCE(SUM(montant), 0) AS total
FROM corrections_solde
WHERE ${invCond('investisseur_id')}
`).get(invParam);
const gainNetTotal = round2(
gainNetRow.interets_nets_total + gainNetRow.cashback_total + correctionsRow.total
);
res.json({
investissements,
interets: { ...interets, annee: annee || null },
cash,
gain_net_depuis_debut: {
total: gainNetTotal,
interets_nets: round2(gainNetRow.interets_nets_total),
cashback: round2(gainNetRow.cashback_total),
corrections: round2(correctionsRow.total),
},
});
});
export default router;
+4
View File
@@ -4,6 +4,8 @@ import investissementsRouter from './investissements.js';
import remboursementsRouter from './remboursements.js';
import depotsRetraitsRouter from './depotsRetraits.js';
import dashboardRouter from './dashboard.js';
import objectifsRouter from './objectifs.js';
import taxreportRouter from './taxreport.js';
const router = Router();
@@ -12,5 +14,7 @@ router.use('/investissements', investissementsRouter);
router.use('/remboursements', remboursementsRouter);
router.use('/depots-retraits', depotsRetraitsRouter);
router.use('/dashboard', dashboardRouter);
router.use('/objectifs', objectifsRouter);
router.use('/taxreport', taxreportRouter);
export default router;
+58
View File
@@ -0,0 +1,58 @@
import { Router } from 'express';
import db from '../../db/index.js';
const router = Router();
/**
* @openapi
* /objectifs:
* get:
* summary: Objectifs de versement et de capital investi
* description: >
* Objectifs fixés dans Paramétrage → Fixation des objectifs, à comparer au réalisé
* (voir crowdlending_get_dashboard / crowdlending_list_depots_retraits pour le
* réalisé). Trois types : `versement_global` (objectif global sur l'investisseur
* principal), `versement_annuel` (enveloppe par détenteur), `capital_investi`
* (objectif de capital investi par plateforme, `plateforme_id` renseigné). La liste
* est vide si la fonctionnalité est désactivée par l'utilisateur, ou si aucun
* objectif n'a encore été saisi.
* tags: [Objectifs]
* security: [{ ApiKeyAuth: [] }]
* parameters:
* - in: query
* name: type
* schema: { type: string, enum: [versement_global, versement_annuel, capital_investi] }
* description: Filtrer par type d'objectif.
* - in: query
* name: annee
* schema: { type: integer }
* description: Filtrer par année.
* responses:
* 200: { description: Liste des objectifs }
*/
router.get('/', (req, res) => {
const { type, annee } = req.query;
// Clé "Famille et entreprises" (scope_all) → tous les investisseurs du foyer ;
// clé mono-investisseur → uniquement les objectifs de cet investisseur (contrairement à
// la route interne /api/objectifs, qui renvoie tout le foyer car elle est scopée par
// session JWT — une clé API mono-investisseur ne doit voir que ses propres objectifs).
const conds = [req.investisseurScopeAll
? 'o.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
: 'o.investisseur_id = ?'];
const args = [req.investisseurScopeAll ? req.userId : req.investisseurId];
if (type) { conds.push('o.type = ?'); args.push(type); }
if (annee) { conds.push('o.annee = ?'); args.push(Number(annee)); }
const rows = db.prepare(`
SELECT o.*, i.nom AS investisseur_nom, p.nom AS plateforme_nom
FROM objectifs o
JOIN investisseurs i ON i.id = o.investisseur_id
LEFT JOIN plateformes p ON p.id = o.plateforme_id
WHERE ${conds.join(' AND ')}
ORDER BY o.annee DESC, i.nom
`).all(...args);
res.json(rows);
});
export default router;
+122
View File
@@ -0,0 +1,122 @@
import { Router } from 'express';
import db from '../../db/index.js';
import { buildCerfa2561, build2778, build2047 } from '../taxreport.js';
const router = Router();
const sumBy = (arr, key) => arr.reduce((s, x) => s + (x[key] ?? 0), 0);
/**
* @openapi
* /taxreport:
* get:
* summary: Synthèse fiscale annuelle (CERFA 2042 / 2561 / 2047 / 2778-SD)
* description: >
* Aide à la préparation de la déclaration de revenus, calculée avec exactement les
* mêmes règles que la page Fiscalité de l'application (mêmes fonctions backend que
* les routes internes /taxreport/cerfa2561, /taxreport/2778 et /taxreport/2047).
* Ce n'est PAS une source officielle : vérifiez chaque montant avant de le reporter
* sur votre déclaration, vous restez seul responsable de son exactitude.
*
* `synthese_2042` reprend les cases 2TT / 2TR / 2BH / 2CK / 2TY telles qu'affichées
* en haut de la page Fiscalité. `cerfa2561` détaille le récapitulatif IFU des
* plateformes françaises soumises au PFU. `cerfa2047` (présent seulement si
* `has_etranger`) détaille, par plateforme étrangère, l'éligibilité au crédit
* d'impôt conventionnel (cases 231 à 238 vs 250) et le report vers 8VL/8PL.
* `cerfa2778_mensuel` (présent seulement si `has_etranger`) donne le détail mensuel
* des intérêts bruts par plateforme étrangère, base de calcul du prélèvement
* forfaitaire obligatoire (PFO) — le PFO n'est légalement dû que si `pfo_assujetti`
* est vrai (seuil de revenu fiscal de référence, cf. Paramétrage → Ma fiscalité).
*
* Limite connue : `case_2BH` / `case_2CK` de `synthese_2042` supposent qu'aucune
* plateforme étrangère n'a été exclue manuellement du calcul PFO dans l'application
* (réglage propre au navigateur, non accessible depuis l'API) — en cas d'exclusion,
* la valeur affichée dans l'app peut différer légèrement de celle-ci.
* tags: [Fiscalité]
* security: [{ ApiKeyAuth: [] }]
* parameters:
* - in: query
* name: annee
* schema: { type: integer }
* description: Année fiscale (ex. 2026). Par défaut, l'année précédente.
* responses:
* 200: { description: Synthèse fiscale annuelle }
*/
router.get('/', (req, res) => {
const annee = req.query.annee || String(new Date().getFullYear() - 1);
const scopeAll = req.investisseurScopeAll;
const invParam = scopeAll ? req.userId : req.investisseurId;
// Clé "Famille et entreprises" (scope_all) → tous les investisseurs du foyer ;
// clé mono-investisseur → filtre sur req.investisseurId. Mêmes fragments SQL que ceux
// construits par les routes internes /taxreport/* (cf. backend/src/routes/taxreport.js),
// passés en paramètre aux fonctions partagées buildCerfa2561/build2778/build2047 pour
// garantir des résultats identiques à ceux affichés dans l'application.
const invCond = scopeAll
? 'i.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
: 'i.investisseur_id = ?';
const invCondBonus = scopeAll
? 'r.bonus_investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)'
: 'r.bonus_investisseur_id = ?';
// pfo_assujetti est une préférence GLOBALE de l'utilisateur (table user_preferences,
// clé user_id) — même mécanisme que UiContext.jsx côté frontend, pas une donnée liée à
// un investisseur en particulier.
const prefRow = db.prepare(
`SELECT value FROM user_preferences WHERE user_id = ? AND key = 'pfo_assujetti'`
).get(req.userId);
const pfoAssujetti = prefRow ? prefRow.value === 'true' : false;
// Au moins une plateforme domiciliée hors de France, tous détenteurs du foyer confondus
// et indépendamment de l'année — la table `plateformes` est scopée par `user_id` (pas
// `investisseur_id`, qui n'est que le détenteur assigné) dans toute l'application (cf.
// GET /api/plateformes), et hasEtranger côté frontend (TaxReport.jsx) est calculé sur
// cette même liste non filtrée par investisseur actif — un comportement volontairement
// répliqué ici, même avec une clé API mono-investisseur.
const hasEtranger = !!db.prepare(
`SELECT 1 FROM plateformes WHERE user_id = ? AND domiciliation != 'FR' LIMIT 1`
).get(req.userId);
const cerfa2561 = buildCerfa2561(annee, invCond, invCondBonus, invParam);
let cerfa2047 = null;
let cerfa2778_mensuel = null;
if (hasEtranger) {
cerfa2047 = build2047(annee, invCond, invCondBonus, invParam);
cerfa2778_mensuel = build2778(annee, invCond, invCondBonus, invParam);
}
// ── 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()) :
// - 2TT / 2TY : cumul direct des lignes 2561 françaises.
// - 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).
// - 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
// ne s'alimente que si la déclaration 2778-SD est active).
const frLignes = cerfa2561.lignes.filter(l => l.domiciliation === 'FR');
const platEtr = cerfa2778_mensuel?.plateformes ?? [];
const etrBA = p => Object.values(p.mois ?? {}).reduce((s, v) => s + v, 0);
const totalBA = platEtr.reduce((s, p) => s + etrBA(p), 0);
const PFO_RATE = 0.128; // même valeur par défaut que celle utilisée par TaxReport.jsx pour cette synthèse
const synthese_2042 = {
case_2TT: sumBy(frLignes, 'case_2TT'),
case_2TR: sumBy(frLignes, 'case_2TR') + Math.round(totalBA),
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_2TY: sumBy(frLignes, 'case_2TY'),
};
res.json({
annee,
has_etranger: hasEtranger,
pfo_assujetti: pfoAssujetti,
synthese_2042,
cerfa2561,
cerfa2047,
cerfa2778_mensuel,
});
});
export default router;