const express = require("express"); const { Pool } = require("pg"); const cors = require("cors"); const jwt = require("jsonwebtoken"); const bcrypt = require("bcryptjs"); const path = require("path"); const app = express(); app.use(cors()); app.use(express.json()); const JWT_SECRET = process.env.JWT_SECRET || "budget-commun-secret-change-me"; const JWT_EXPIRY = "30d"; const ADMIN_USER = process.env.ADMIN_USER || "user1"; // ── Database ────────────────────────────────────────────────── const pool = new Pool({ connectionString: process.env.DATABASE_URL || "postgres://budget:budget@localhost:5432/budget", }); async function waitForDb(retries = 15, delay = 2000) { for (let i = 0; i < retries; i++) { try { await pool.query("SELECT 1"); console.log("PostgreSQL connecté."); return; } catch { console.log(`Attente de la base de données... (${i + 1}/${retries})`); await new Promise(r => setTimeout(r, delay)); } } throw new Error("Impossible de se connecter à PostgreSQL."); } async function initDb() { await pool.query(` CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, username TEXT UNIQUE NOT NULL, password TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS budgets ( id SERIAL PRIMARY KEY, nom TEXT NOT NULL, type TEXT NOT NULL CHECK(type IN ('commun','perso')), owner_user_id INTEGER REFERENCES users(id) ); CREATE TABLE IF NOT EXISTS categories ( id SERIAL PRIMARY KEY, slug TEXT UNIQUE NOT NULL, label TEXT NOT NULL, emoji TEXT NOT NULL DEFAULT '📦', active INTEGER NOT NULL DEFAULT 1, sort INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS expenses ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, amount REAL NOT NULL, cat TEXT NOT NULL DEFAULT 'autre', freq TEXT NOT NULL DEFAULT 'mensuel', month TEXT, budget_id INTEGER NOT NULL DEFAULT 1, created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS pointage ( id SERIAL PRIMARY KEY, expense_id INTEGER NOT NULL, month TEXT NOT NULL, pointed INTEGER NOT NULL DEFAULT 0, pointed_at TEXT, amount REAL NOT NULL, name TEXT NOT NULL, budget_id INTEGER NOT NULL DEFAULT 1, UNIQUE(expense_id, month) ); CREATE TABLE IF NOT EXISTS savings ( id SERIAL PRIMARY KEY, budget_id INTEGER NOT NULL REFERENCES budgets(id), name TEXT NOT NULL, emoji TEXT NOT NULL DEFAULT '💰', monthly_amount REAL NOT NULL DEFAULT 0, balance REAL NOT NULL DEFAULT 0, target REAL, closed BOOLEAN NOT NULL DEFAULT FALSE, expense_id INTEGER, created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS savings_transactions ( id SERIAL PRIMARY KEY, saving_id INTEGER NOT NULL REFERENCES savings(id) ON DELETE CASCADE, amount REAL NOT NULL, note TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ DEFAULT NOW() ); `); await pool.query(` ALTER TABLE expenses ADD COLUMN IF NOT EXISTS installments INTEGER; ALTER TABLE expenses ADD COLUMN IF NOT EXISTS installments_paid INTEGER NOT NULL DEFAULT 0; `); // Seed users const { rows: [{ c: userCount }] } = await pool.query("SELECT COUNT(*) AS c FROM users"); if (parseInt(userCount) === 0) { await pool.query("INSERT INTO users (username,password) VALUES ($1,$2)", ["user1", bcrypt.hashSync("password1", 10)]); await pool.query("INSERT INTO users (username,password) VALUES ($1,$2)", ["user2", bcrypt.hashSync("password2", 10)]); console.log("Utilisateurs créés : user1 / user2"); } // Seed budgets const { rows: [{ c: budgetCount }] } = await pool.query("SELECT COUNT(*) AS c FROM budgets"); if (parseInt(budgetCount) === 0) { const { rows: [u1] } = await pool.query("SELECT id FROM users WHERE username='user1'"); const { rows: [u2] } = await pool.query("SELECT id FROM users WHERE username='user2'"); await pool.query("INSERT INTO budgets (id,nom,type,owner_user_id) VALUES (1,'Commun','commun',NULL) ON CONFLICT DO NOTHING"); await pool.query("INSERT INTO budgets (id,nom,type,owner_user_id) VALUES (2,'Perso A','perso',$1) ON CONFLICT DO NOTHING", [u1.id]); await pool.query("INSERT INTO budgets (id,nom,type,owner_user_id) VALUES (3,'Perso B','perso',$1) ON CONFLICT DO NOTHING", [u2.id]); await pool.query("SELECT setval('budgets_id_seq', 3)"); console.log("Budgets créés : Commun, Perso A, Perso B"); } // Seed categories const { rows: [{ c: catCount }] } = await pool.query("SELECT COUNT(*) AS c FROM categories"); if (parseInt(catCount) === 0) { const defaults = [ { slug:"logement", label:"Logement", emoji:"🏠", sort:1 }, { slug:"alimentation", label:"Courses", emoji:"🛒", sort:2 }, { slug:"transport", label:"Transport", emoji:"🚗", sort:3 }, { slug:"abonnements", label:"Abonnements", emoji:"📱", sort:4 }, { slug:"sante", label:"Santé", emoji:"💊", sort:5 }, { slug:"loisirs", label:"Loisirs", emoji:"🎉", sort:6 }, { slug:"enfants", label:"Enfants", emoji:"👶", sort:7 }, { slug:"autre", label:"Autre", emoji:"📦", sort:8 }, ]; for (const c of defaults) { await pool.query("INSERT INTO categories (slug,label,emoji,active,sort) VALUES ($1,$2,$3,1,$4)", [c.slug, c.label, c.emoji, c.sort]); } } await pool.query( "INSERT INTO categories (slug,label,emoji,active,sort) VALUES ('epargne','Épargne','💰',1,9) ON CONFLICT DO NOTHING" ); // Seed settings const defs = { nameA:"", nameB:"", incomeA:"", incomeB:"", sliderVal:"63", sliderLocked:"false", setupDone:"false" }; for (const [k, v] of Object.entries(defs)) { await pool.query("INSERT INTO settings (key,value) VALUES ($1,$2) ON CONFLICT (key) DO NOTHING", [k, v]); } } // ── Middleware auth ─────────────────────────────────────────── function requireAuth(req, res, next) { const h = req.headers.authorization; if (!h || !h.startsWith("Bearer ")) return res.status(401).json({ error: "Non authentifié" }); try { req.user = jwt.verify(h.slice(7), JWT_SECRET); next(); } catch { res.status(401).json({ error: "Token invalide ou expiré" }); } } function requireAdmin(req, res, next) { requireAuth(req, res, () => { if (req.user.username !== ADMIN_USER) return res.status(403).json({ error: "Accès réservé à l'administrateur" }); next(); }); } // ── Auth routes ─────────────────────────────────────────────── app.post("/api/auth/login", async (req, res) => { try { const { username, password } = req.body; if (!username || !password) return res.status(400).json({ error: "Champs manquants" }); const { rows } = await pool.query("SELECT * FROM users WHERE username=$1", [username]); const user = rows[0]; if (!user || !bcrypt.compareSync(password, user.password)) return res.status(401).json({ error: "Identifiants incorrects" }); const token = jwt.sign({ id: user.id, username: user.username }, JWT_SECRET, { expiresIn: JWT_EXPIRY }); res.json({ token, username: user.username, isAdmin: user.username === ADMIN_USER }); } catch (err) { res.status(500).json({ error: err.message }); } }); app.get("/api/auth/me", requireAuth, async (req, res) => { try { const { rows } = await pool.query( "SELECT username FROM users WHERE id=$1 AND username=$2", [req.user.id, req.user.username] ); if (!rows[0]) return res.status(401).json({ error: "Session expirée" }); res.json({ username: rows[0].username, isAdmin: rows[0].username === ADMIN_USER }); } catch (err) { res.status(500).json({ error: err.message }); } }); app.post("/api/auth/change-password", requireAuth, async (req, res) => { try { const { currentPassword, newPassword } = req.body; if (!currentPassword || !newPassword) return res.status(400).json({ error: "Champs manquants" }); if (newPassword.length < 6) return res.status(400).json({ error: "6 caractères minimum" }); const { rows } = await pool.query("SELECT * FROM users WHERE id=$1", [req.user.id]); if (!bcrypt.compareSync(currentPassword, rows[0].password)) return res.status(401).json({ error: "Mot de passe actuel incorrect" }); await pool.query("UPDATE users SET password=$1 WHERE id=$2", [bcrypt.hashSync(newPassword, 10), req.user.id]); res.json({ ok: true }); } catch (err) { res.status(500).json({ error: err.message }); } }); // ── Settings ────────────────────────────────────────────────── app.get("/api/settings", requireAuth, async (req, res) => { try { const { rows } = await pool.query("SELECT key,value FROM settings"); const obj = {}; rows.forEach(r => obj[r.key] = r.value); res.json(obj); } catch (err) { res.status(500).json({ error: err.message }); } }); app.post("/api/settings", requireAuth, async (req, res) => { try { for (const [k, v] of Object.entries(req.body)) { await pool.query( "INSERT INTO settings (key,value) VALUES ($1,$2) ON CONFLICT (key) DO UPDATE SET value=$2", [k, String(v)] ); } res.json({ ok: true }); } catch (err) { res.status(500).json({ error: err.message }); } }); // ── Categories ──────────────────────────────────────────────── app.get("/api/categories", requireAuth, async (req, res) => { try { const { rows } = await pool.query("SELECT * FROM categories ORDER BY sort ASC"); res.json(rows); } catch (err) { res.status(500).json({ error: err.message }); } }); app.post("/api/categories", requireAdmin, async (req, res) => { try { const { label, emoji } = req.body; if (!label) return res.status(400).json({ error: "label requis" }); const slug = label.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g,"").replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"") + "-" + Date.now(); const { rows: [{ m }] } = await pool.query("SELECT MAX(sort) AS m FROM categories"); const maxSort = m || 0; const { rows } = await pool.query( "INSERT INTO categories (slug,label,emoji,active,sort) VALUES ($1,$2,$3,1,$4) RETURNING *", [slug, label.trim(), emoji || "📦", maxSort + 1] ); res.json(rows[0]); } catch (err) { res.status(500).json({ error: err.message }); } }); app.put("/api/categories/:id", requireAdmin, async (req, res) => { try { const { label, emoji, active } = req.body; const { rows } = await pool.query("SELECT * FROM categories WHERE id=$1", [req.params.id]); if (!rows[0]) return res.status(404).json({ error: "Catégorie introuvable" }); const cat = rows[0]; const { rows: updated } = await pool.query( "UPDATE categories SET label=$1,emoji=$2,active=$3 WHERE id=$4 RETURNING *", [label ?? cat.label, emoji ?? cat.emoji, active !== undefined ? (active ? 1 : 0) : cat.active, req.params.id] ); res.json(updated[0]); } catch (err) { res.status(500).json({ error: err.message }); } }); app.delete("/api/categories/:id", requireAdmin, async (req, res) => { try { const { rows } = await pool.query("SELECT * FROM categories WHERE id=$1", [req.params.id]); if (!rows[0]) return res.status(404).json({ error: "Catégorie introuvable" }); await pool.query("UPDATE expenses SET cat='autre' WHERE cat=$1", [rows[0].slug]); await pool.query("DELETE FROM categories WHERE id=$1", [req.params.id]); res.json({ ok: true }); } catch (err) { res.status(500).json({ error: err.message }); } }); // ── Expenses ────────────────────────────────────────────────── app.get("/api/expenses", requireAuth, async (req, res) => { try { const budgetId = parseInt(req.query.budget_id || req.headers["x-budget-id"]) || 1; const { rows } = await pool.query("SELECT * FROM expenses WHERE budget_id=$1 ORDER BY created_at ASC", [budgetId]); res.json(rows); } catch (err) { res.status(500).json({ error: err.message }); } }); app.post("/api/expenses", requireAuth, async (req, res) => { try { const { name, amount, cat, freq, month, installments } = req.body; if (!name || !amount) return res.status(400).json({ error: "name and amount required" }); const finalFreq = month ? "ponctuel" : (freq || "mensuel"); const budgetId = parseInt(req.headers["x-budget-id"]) || 1; const { rows } = await pool.query( "INSERT INTO expenses (name,amount,cat,freq,month,budget_id,installments) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *", [name, amount, cat || "autre", finalFreq, month || null, budgetId, installments || null] ); res.json(rows[0]); } catch (err) { res.status(500).json({ error: err.message }); } }); app.put("/api/expenses/:id", requireAuth, async (req, res) => { try { const { name, amount, cat, freq, month, installments, installments_paid } = req.body; if (!name || !amount) return res.status(400).json({ error: "name and amount required" }); const { rows } = await pool.query("SELECT * FROM expenses WHERE id=$1", [req.params.id]); if (!rows[0]) return res.status(404).json({ error: "Dépense introuvable" }); const existing = rows[0]; const finalFreq = month ? "ponctuel" : (freq || existing.freq); const { rows: updated } = await pool.query( "UPDATE expenses SET name=$1,amount=$2,cat=$3,freq=$4,month=$5,installments=$6,installments_paid=$7 WHERE id=$8 RETURNING *", [name, amount, cat || existing.cat, finalFreq, month || null, installments ?? existing.installments, installments_paid !== undefined ? installments_paid : existing.installments_paid, req.params.id] ); res.json(updated[0]); } catch (err) { res.status(500).json({ error: err.message }); } }); app.post("/api/expenses/:id/pay-installment", requireAuth, async (req, res) => { try { const { rows: [exp] } = await pool.query("SELECT * FROM expenses WHERE id=$1", [req.params.id]); if (!exp) return res.status(404).json({ error: "Dépense introuvable" }); if (!exp.installments || exp.installments_paid >= exp.installments) return res.status(400).json({ error: "Toutes les mensualités sont déjà payées" }); const { rows: [updated] } = await pool.query( "UPDATE expenses SET installments_paid = installments_paid + 1 WHERE id=$1 RETURNING *", [req.params.id] ); res.json(updated); } catch (err) { res.status(500).json({ error: err.message }); } }); app.delete("/api/expenses/:id", requireAuth, async (req, res) => { try { await pool.query("DELETE FROM expenses WHERE id=$1", [req.params.id]); res.json({ ok: true }); } catch (err) { res.status(500).json({ error: err.message }); } }); // ── Savings ─────────────────────────────────────────────────── app.get("/api/savings", requireAuth, async (req, res) => { const budgetId = req.query.budget_id || req.headers["x-budget-id"] || 1; const { rows } = await pool.query( "SELECT * FROM savings WHERE budget_id=$1 ORDER BY closed, created_at DESC", [budgetId] ); res.json(rows); }); app.post("/api/savings", requireAuth, async (req, res) => { const budgetId = req.headers["x-budget-id"] || 1; const { name, emoji = "💰", monthly_amount = 0, target } = req.body; if (!name) return res.status(400).json({ error: "Nom requis" }); // Create linked expense if monthly_amount > 0 let expenseId = null; if (parseFloat(monthly_amount) > 0) { const { rows: [exp] } = await pool.query( "INSERT INTO expenses (name,amount,cat,freq,budget_id) VALUES ($1,$2,'epargne','mensuel',$3) RETURNING *", [emoji + " " + name, parseFloat(monthly_amount), budgetId] ); expenseId = exp.id; } const { rows: [saving] } = await pool.query( "INSERT INTO savings (budget_id,name,emoji,monthly_amount,balance,target,expense_id) VALUES ($1,$2,$3,$4,0,$5,$6) RETURNING *", [budgetId, name, emoji, parseFloat(monthly_amount) || 0, target ? parseFloat(target) : null, expenseId] ); res.json(saving); }); app.put("/api/savings/:id", requireAuth, async (req, res) => { const { name, emoji, monthly_amount, target } = req.body; const { rows: [saving] } = await pool.query("SELECT * FROM savings WHERE id=$1", [req.params.id]); if (!saving) return res.status(404).json({ error: "Introuvable" }); // Update linked expense amount if changed if (saving.expense_id && monthly_amount !== undefined) { if (parseFloat(monthly_amount) > 0) { await pool.query( "UPDATE expenses SET amount=$1, name=$2 WHERE id=$3", [parseFloat(monthly_amount), (emoji || saving.emoji) + " " + (name || saving.name), saving.expense_id] ); } else { await pool.query("DELETE FROM expenses WHERE id=$1", [saving.expense_id]); await pool.query("UPDATE savings SET expense_id=NULL WHERE id=$1", [saving.id]); } } else if (!saving.expense_id && monthly_amount && parseFloat(monthly_amount) > 0) { // Create expense if now has monthly amount const { rows: [exp] } = await pool.query( "INSERT INTO expenses (name,amount,cat,freq,budget_id) VALUES ($1,$2,'epargne','mensuel',$3) RETURNING *", [(emoji || saving.emoji) + " " + (name || saving.name), parseFloat(monthly_amount), saving.budget_id] ); await pool.query("UPDATE savings SET expense_id=$1 WHERE id=$2", [exp.id, saving.id]); } const { rows: [updated] } = await pool.query( `UPDATE savings SET name = COALESCE($1, name), emoji = COALESCE($2, emoji), monthly_amount = COALESCE($3, monthly_amount), target = $4 WHERE id=$5 RETURNING *`, [name, emoji, monthly_amount !== undefined ? parseFloat(monthly_amount) : null, target ? parseFloat(target) : null, req.params.id] ); res.json(updated); }); app.post("/api/savings/:id/transaction", requireAuth, async (req, res) => { const { amount, note = "" } = req.body; if (!amount || isNaN(parseFloat(amount))) return res.status(400).json({ error: "Montant invalide" }); await pool.query( "INSERT INTO savings_transactions (saving_id,amount,note) VALUES ($1,$2,$3)", [req.params.id, parseFloat(amount), note] ); const { rows: [saving] } = await pool.query( "UPDATE savings SET balance = balance + $1 WHERE id=$2 RETURNING *", [parseFloat(amount), req.params.id] ); res.json(saving); }); app.get("/api/savings/:id/transactions", requireAuth, async (req, res) => { const { rows } = await pool.query( "SELECT * FROM savings_transactions WHERE saving_id=$1 ORDER BY created_at DESC LIMIT 20", [req.params.id] ); res.json(rows); }); app.post("/api/savings/:id/close", requireAuth, async (req, res) => { const { rows: [saving] } = await pool.query("SELECT * FROM savings WHERE id=$1", [req.params.id]); if (!saving) return res.status(404).json({ error: "Introuvable" }); // Delete linked expense if (saving.expense_id) { await pool.query("DELETE FROM expenses WHERE id=$1", [saving.expense_id]); } const { rows: [updated] } = await pool.query( "UPDATE savings SET closed=TRUE, expense_id=NULL WHERE id=$1 RETURNING *", [req.params.id] ); res.json(updated); }); app.delete("/api/savings/:id", requireAuth, async (req, res) => { const { rows: [saving] } = await pool.query("SELECT * FROM savings WHERE id=$1", [req.params.id]); if (!saving) return res.status(404).json({ error: "Introuvable" }); if (saving.expense_id) { await pool.query("DELETE FROM expenses WHERE id=$1", [saving.expense_id]); } await pool.query("DELETE FROM savings WHERE id=$1", [req.params.id]); res.json({ ok: true }); }); // ── Admin ───────────────────────────────────────────────────── app.get("/api/admin/users", requireAdmin, async (req, res) => { try { const { rows } = await pool.query("SELECT id,username FROM users"); res.json(rows); } catch (err) { res.status(500).json({ error: err.message }); } }); // ── Pointage ────────────────────────────────────────────────── const POINTAGE_START_DAY = 26; function currentMonth() { const d = new Date(); if (d.getDate() < POINTAGE_START_DAY) { const prev = new Date(d.getFullYear(), d.getMonth() - 1, 1); return prev.getFullYear() + '-' + String(prev.getMonth() + 1).padStart(2, '0'); } return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0'); } function prevMonth(m) { const [y, mo] = m.split('-').map(Number); const d = new Date(y, mo - 2, 1); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0'); } async function generatePointageForMonth(month, budgetId = 1) { const { rows: recurring } = await pool.query( "SELECT * FROM expenses WHERE freq != 'ponctuel' AND budget_id=$1", [budgetId] ); const client = await pool.connect(); try { await client.query("BEGIN"); for (const e of recurring) { await client.query( "INSERT INTO pointage (expense_id,month,pointed,amount,name,budget_id) VALUES ($1,$2,0,$3,$4,$5) ON CONFLICT (expense_id,month) DO NOTHING", [e.id, month, e.amount, e.name, budgetId] ); } await client.query("COMMIT"); } catch (err) { await client.query("ROLLBACK"); throw err; } finally { client.release(); } } app.get("/api/pointage", requireAuth, async (req, res) => { try { const month = req.query.month || currentMonth(); const budgetId = parseInt(req.headers["x-budget-id"]) || 1; await generatePointageForMonth(month, budgetId); const { rows } = await pool.query(` SELECT p.*,e.cat,e.freq FROM pointage p LEFT JOIN expenses e ON p.expense_id=e.id WHERE p.month=$1 AND p.budget_id=$2 ORDER BY p.pointed ASC,p.name ASC `, [month, budgetId]); const prev = prevMonth(month); await generatePointageForMonth(prev, budgetId); const { rows: unpointedPrev } = await pool.query(` SELECT p.*,e.cat,e.freq FROM pointage p LEFT JOIN expenses e ON p.expense_id=e.id WHERE p.month=$1 AND p.pointed=0 AND p.budget_id=$2 ORDER BY p.name ASC `, [prev, budgetId]); res.json({ month, rows, unpointedPrev, prevMonth: prev }); } catch (err) { res.status(500).json({ error: err.message }); } }); app.put("/api/pointage/:id", requireAuth, async (req, res) => { try { const { pointed } = req.body; await pool.query( "UPDATE pointage SET pointed=$1,pointed_at=$2 WHERE id=$3", [pointed ? 1 : 0, pointed ? new Date().toISOString() : null, req.params.id] ); const { rows } = await pool.query("SELECT * FROM pointage WHERE id=$1", [req.params.id]); res.json(rows[0]); } catch (err) { res.status(500).json({ error: err.message }); } }); app.post("/api/pointage/generate", requireAuth, async (req, res) => { try { const month = req.body.month || currentMonth(); const budgetId = parseInt(req.headers["x-budget-id"]) || 1; await generatePointageForMonth(month, budgetId); res.json({ ok: true, month }); } catch (err) { res.status(500).json({ error: err.message }); } }); // ── Serve frontend ──────────────────────────────────────────── app.use(express.static(path.join(__dirname, "public"))); app.get("*", (req, res) => res.sendFile(path.join(__dirname, "public", "index.html"))); // ── Start ───────────────────────────────────────────────────── async function start() { await waitForDb(); await initDb(); app.listen(3000, () => console.log("Budget Commun running on :3000")); } start().catch(err => { console.error("Erreur au démarrage :", err); process.exit(1); });