This commit is contained in:
ocroguennec committed 2026-09-28 08:24:16 +02:00
1 parent 624d32ff7c
commit bce89d8112
18 files changed
+663 -28

No files matched your search

+13 -6
View File
@@ -11,19 +11,26 @@ const Schema = z.object({
investisseur_id: z.number().int().positive().nullable().optional(),
banque: z.string().nullable().optional(),
institution_id: z.number().int().positive().nullable().optional(),
// Phase 3a (28/09/26) — rattachement au référentiel enveloppes_referentiel, purement
// descriptif et non-destructif (cf. commentaire de migration dans db/index.js) : n'a aucune
// incidence sur `type`, qui reste le champ utilisé par la logique de remboursement/frais
// existante. Non contraint à eligible_compte=1 côté Zod — le filtrage se fait côté frontend.
enveloppe_id: z.number().int().positive().nullable().optional(),
exoneration_fiscale: z.enum(['aucune', 'pfnl_5ans']).default('aucune'),
});
router.get('/', (req, res) => {
const rows = db.prepare(`
SELECT c.id, c.nom, c.type, c.banque, c.institution_id, c.exoneration_fiscale, c.investisseur_id, c.created_at,
SELECT c.id, c.nom, c.type, c.banque, c.institution_id, c.enveloppe_id, c.exoneration_fiscale, c.investisseur_id, c.created_at,
inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom,
inv.type AS investisseur_type, inv.type_fiscal AS investisseur_type_fiscal,
ir.nom AS institution_nom, ir.domiciliation AS institution_domiciliation,
ir.logo_filename AS institution_logo_filename, ir.icone_filename AS institution_icone_filename
ir.logo_filename AS institution_logo_filename, ir.icone_filename AS institution_icone_filename,
er.nom AS enveloppe_nom
FROM comptes c
LEFT JOIN investisseurs inv ON inv.id = c.investisseur_id
LEFT JOIN institutions_referentiel ir ON ir.id = c.institution_id
LEFT JOIN enveloppes_referentiel er ON er.id = c.enveloppe_id
WHERE c.user_id = ?
ORDER BY c.nom
`).all(req.user.id);
@@ -34,8 +41,8 @@ router.post('/', (req, res, next) => {
try {
const body = Schema.parse(req.body);
const r = db.prepare(
'INSERT INTO comptes (user_id, nom, type, banque, institution_id, investisseur_id, exoneration_fiscale) VALUES (?,?,?,?,?,?,?)'
).run(req.user.id, body.nom, body.type, body.banque || null, body.institution_id ?? null, body.investisseur_id ?? null, body.exoneration_fiscale ?? 'aucune');
'INSERT INTO comptes (user_id, nom, type, banque, institution_id, enveloppe_id, investisseur_id, exoneration_fiscale) VALUES (?,?,?,?,?,?,?,?)'
).run(req.user.id, body.nom, body.type, body.banque || null, body.institution_id ?? null, body.enveloppe_id ?? null, body.investisseur_id ?? null, body.exoneration_fiscale ?? 'aucune');
res.status(201).json({ id: r.lastInsertRowid, ...body });
} catch (e) { next(e); }
});
@@ -44,8 +51,8 @@ router.put('/:id', (req, res, next) => {
try {
const body = Schema.parse(req.body);
const r = db.prepare(
`UPDATE comptes SET nom=?, type=?, banque=?, institution_id=?, investisseur_id=?, exoneration_fiscale=?, updated_at=datetime('now') WHERE id=? AND user_id=?`
).run(body.nom, body.type, body.banque || null, body.institution_id ?? null, body.investisseur_id ?? null, body.exoneration_fiscale ?? 'aucune', req.params.id, req.user.id);
`UPDATE comptes SET nom=?, type=?, banque=?, institution_id=?, enveloppe_id=?, investisseur_id=?, exoneration_fiscale=?, updated_at=datetime('now') WHERE id=? AND user_id=?`
).run(body.nom, body.type, body.banque || null, body.institution_id ?? null, body.enveloppe_id ?? null, body.investisseur_id ?? null, body.exoneration_fiscale ?? 'aucune', req.params.id, req.user.id);
if (r.changes === 0) throw new HttpError(404, 'Not found');
res.json({ id: Number(req.params.id), ...body });
} catch (e) { next(e); }
+96
View File
@@ -0,0 +1,96 @@
import { Router } from 'express';
import { z } from 'zod';
import db from '../db/index.js';
import { HttpError } from '../middleware/errorHandler.js';
/**
* routes/emprunts.js — Passif patrimonial (Phase 3c, 28/09/26, demande Olivier). Objet séparé
* de comptes/enveloppes_referentiel : un emprunt est structurellement différent d'un actif (le
* capital restant dû diminue avec le temps, porte un taux et un échéancier fixés à la
* souscription) — cf. "Réflexion prospective" du plan de déploiement Enveloppes & Supports (doc
* projet), point 3. Mêmes conventions que comptes.js : scope par user_id, détenteur
* (investisseur_id) et établissement prêteur (institution_id) optionnels et nullables.
*/
const router = Router();
const Schema = z.object({
nom: z.string().min(1),
investisseur_id: z.number().int().positive().nullable().optional(),
institution_id: z.number().int().positive().nullable().optional(),
montant_emprunte: z.number().positive(),
capital_restant_du: z.number().nonnegative(),
taux_nominal: z.number().nullable().optional(),
date_debut: z.string().nullable().optional(),
duree_mois: z.number().int().positive().nullable().optional(),
mensualite: z.number().nullable().optional(),
notes: z.string().nullable().optional(),
});
router.get('/', (req, res) => {
const rows = db.prepare(`
SELECT e.id, e.nom, e.investisseur_id, e.institution_id, e.montant_emprunte, e.capital_restant_du,
e.taux_nominal, e.date_debut, e.duree_mois, e.mensualite, e.notes, e.created_at,
inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom,
inv.type AS investisseur_type, inv.type_fiscal AS investisseur_type_fiscal,
ir.nom AS institution_nom, ir.domiciliation AS institution_domiciliation,
ir.logo_filename AS institution_logo_filename, ir.icone_filename AS institution_icone_filename
FROM emprunts e
LEFT JOIN investisseurs inv ON inv.id = e.investisseur_id
LEFT JOIN institutions_referentiel ir ON ir.id = e.institution_id
WHERE e.user_id = ?
ORDER BY e.nom
`).all(req.user.id);
res.json(rows);
});
router.post('/', (req, res, next) => {
try {
const body = Schema.parse(req.body);
if (body.capital_restant_du > body.montant_emprunte) {
throw new HttpError(400, 'Le capital restant dû ne peut pas dépasser le montant emprunté');
}
const r = db.prepare(`
INSERT INTO emprunts (user_id, nom, investisseur_id, institution_id, montant_emprunte, capital_restant_du, taux_nominal, date_debut, duree_mois, mensualite, notes)
VALUES (?,?,?,?,?,?,?,?,?,?,?)
`).run(
req.user.id, body.nom, body.investisseur_id ?? null, body.institution_id ?? null,
body.montant_emprunte, body.capital_restant_du, body.taux_nominal ?? null,
body.date_debut || null, body.duree_mois ?? null, body.mensualite ?? null, body.notes || null,
);
res.status(201).json({ id: r.lastInsertRowid, ...body });
} catch (e) { next(e); }
});
router.put('/:id', (req, res, next) => {
try {
const body = Schema.parse(req.body);
if (body.capital_restant_du > body.montant_emprunte) {
throw new HttpError(400, 'Le capital restant dû ne peut pas dépasser le montant emprunté');
}
const r = db.prepare(`
UPDATE emprunts
SET nom=?, investisseur_id=?, institution_id=?, montant_emprunte=?, capital_restant_du=?,
taux_nominal=?, date_debut=?, duree_mois=?, mensualite=?, notes=?, updated_at=datetime('now')
WHERE id=? AND user_id=?
`).run(
body.nom, body.investisseur_id ?? null, body.institution_id ?? null,
body.montant_emprunte, body.capital_restant_du, body.taux_nominal ?? null,
body.date_debut || null, body.duree_mois ?? null, body.mensualite ?? null, body.notes || null,
req.params.id, req.user.id,
);
if (r.changes === 0) throw new HttpError(404, 'Not found');
res.json({ id: Number(req.params.id), ...body });
} catch (e) { next(e); }
});
router.delete('/:id', (req, res, next) => {
try {
const r = db.prepare('DELETE FROM emprunts WHERE id=? AND user_id=?')
.run(req.params.id, req.user.id);
if (r.changes === 0) throw new HttpError(404, 'Not found');
res.status(204).end();
} catch (e) { next(e); }
});
export default router;
+12 -11
View File
@@ -25,12 +25,13 @@ const router = Router();
// Toutes ces routes sont montées sous requireAdmin dans server.js
const Schema = z.object({
nom: z.string().min(1).max(200),
description: z.string().nullable().optional(),
plafond_eur: z.number().positive().nullable().optional(),
regime_fiscal: z.string().nullable().optional(),
categories_ids: z.array(z.number().int()).optional().default([]),
supports_ids: z.array(z.number().int()).optional().default([]),
nom: z.string().min(1).max(200),
description: z.string().nullable().optional(),
plafond_eur: z.number().positive().nullable().optional(),
regime_fiscal: z.string().nullable().optional(),
eligible_compte: z.boolean().optional().default(false),
categories_ids: z.array(z.number().int()).optional().default([]),
supports_ids: z.array(z.number().int()).optional().default([]),
});
// ── Helpers ────────────────────────────────────────────────────────────────
@@ -114,9 +115,9 @@ router.post('/', (req, res, next) => {
if (existing) throw new HttpError(409, `Une enveloppe "${data.nom}" existe déjà`);
const r = db.prepare(`
INSERT INTO enveloppes_referentiel (nom, description, plafond_eur, regime_fiscal, updated_at)
VALUES (?,?,?,?, datetime('now'))
`).run(data.nom, data.description || null, data.plafond_eur ?? null, data.regime_fiscal || null);
INSERT INTO enveloppes_referentiel (nom, description, plafond_eur, regime_fiscal, eligible_compte, updated_at)
VALUES (?,?,?,?,?, datetime('now'))
`).run(data.nom, data.description || null, data.plafond_eur ?? null, data.regime_fiscal || null, data.eligible_compte ? 1 : 0);
const enveloppeId = r.lastInsertRowid;
db.transaction(() => {
@@ -144,9 +145,9 @@ router.put('/:id', (req, res, next) => {
db.prepare(`
UPDATE enveloppes_referentiel
SET nom=?, description=?, plafond_eur=?, regime_fiscal=?, updated_at=datetime('now')
SET nom=?, description=?, plafond_eur=?, regime_fiscal=?, eligible_compte=?, updated_at=datetime('now')
WHERE id=?
`).run(data.nom, data.description || null, data.plafond_eur ?? null, data.regime_fiscal || null, row.id);
`).run(data.nom, data.description || null, data.plafond_eur ?? null, data.regime_fiscal || null, data.eligible_compte ? 1 : 0, row.id);
db.transaction(() => {
saveCategories(row.id, data.categories_ids);
@@ -0,0 +1,46 @@
import { Router } from 'express';
import db from '../db/index.js';
/**
* routes/enveloppesReferentielPublic.js — lecture seule du référentiel "Enveloppes & Supports
* d'épargne" pour tous les utilisateurs authentifiés (pas requireAdmin), sur le modèle de
* institutionsReferentielPublic.js. Catalogue transverse : on expose ici une LISTE complète
* (avec catégories, pour regroupement par familles côté frontend), utilisée par le sélecteur
* "Enveloppe" de Paramètres > Mes comptes (Phase 3a, cf. plan de déploiement Enveloppes &
* Supports). Le champ `eligible_compte` permet au frontend de ne proposer, dans ce sélecteur,
* que le sous-ensemble "compte financier" — cadrage validé par Olivier via AskUserQuestion
* (28/09/26) — sans empêcher un futur écran (Phase 3b) de consommer la liste complète.
*/
const router = Router();
// Monté sous requireAuth (sans requireAdmin) — lecture seule.
function attachCategories(rows) {
if (rows.length === 0) return rows;
const ids = rows.map(r => r.id);
const links = db.prepare(`
SELECT rc.enveloppe_id, c.id, c.nom
FROM referentiel_categories_enveloppes rc
JOIN categories_enveloppes c ON c.id = rc.categorie_id
WHERE rc.enveloppe_id IN (${ids.map(() => '?').join(',')})
ORDER BY c.nom
`).all(...ids);
const map = {};
for (const l of links) {
if (!map[l.enveloppe_id]) map[l.enveloppe_id] = [];
map[l.enveloppe_id].push({ id: l.id, nom: l.nom });
}
return rows.map(r => ({ ...r, categories: map[r.id] || [] }));
}
/* GET /api/enveloppes-referentiel-public — liste complète (lecture seule) */
router.get('/', (_req, res) => {
const rows = db.prepare(`
SELECT id, nom, eligible_compte
FROM enveloppes_referentiel
ORDER BY nom
`).all();
res.json(attachCategories(rows));
});
export default router;