diff --git a/app.js b/app.js
index 502e039..c7c0d40 100644
--- a/app.js
+++ b/app.js
@@ -34,12 +34,14 @@ const indexRouter = require('./routes/index');
const adminRouter = require('./routes/admin');
const apiRouter = require('./routes/api');
const billingRouter = require('./routes/billing');
+const financeRouter = require('./routes/finance');
const cron = require('node-cron');
app.use('/', indexRouter);
app.use('/admin', adminRouter);
app.use('/api', apiRouter);
app.use('/admin/billing', billingRouter);
+app.use('/admin/finance', financeRouter);
// 404 Handler
app.use((req, res) => {
diff --git a/public/css/style.css b/public/css/style.css
index 4d6a27a..aaac667 100644
--- a/public/css/style.css
+++ b/public/css/style.css
@@ -1169,3 +1169,82 @@ body:not(.admin-body) > * {
top: 0;
z-index: 1;
}
+
+/* ================================================
+ FINANZÜBERSICHT
+ ================================================ */
+.finance-title { font-size:1.8rem; font-weight:800; margin-bottom:20px; }
+
+.finance-kpi-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 14px;
+ margin-bottom: 28px;
+}
+.kpi-card {
+ border-radius: 14px;
+ padding: 18px 20px;
+ border: 1.5px solid var(--border);
+ background: white;
+}
+.kpi-label { font-size:0.75rem; font-weight:700; text-transform:uppercase; letter-spacing:0.5px; margin-bottom:8px; }
+.kpi-value { font-size:1.7rem; font-weight:800; margin-bottom:4px; }
+.kpi-sub { font-size:0.78rem; color:var(--text-muted); }
+.kpi-blue .kpi-value { color: var(--primary); }
+.kpi-red .kpi-value { color: var(--error); }
+.kpi-orange .kpi-value { color: var(--warning); }
+.kpi-purple .kpi-value { color: #7c3aed; }
+.kpi-green .kpi-value { color: var(--success); }
+.kpi-yellow .kpi-value { color: #b45309; }
+
+/* Tabs */
+.finance-tabs {
+ display: flex;
+ gap: 4px;
+ border-bottom: 2px solid var(--border);
+ margin-bottom: 20px;
+ flex-wrap: wrap;
+}
+.ftab {
+ padding: 10px 16px;
+ background: none;
+ border: none;
+ border-bottom: 2px solid transparent;
+ margin-bottom: -2px;
+ font-family: 'Outfit', sans-serif;
+ font-size: 0.88rem;
+ font-weight: 600;
+ color: var(--text-muted);
+ cursor: pointer;
+ transition: all 0.15s;
+ white-space: nowrap;
+}
+.ftab:hover { color: var(--text); }
+.ftab.active { color: var(--primary); border-bottom-color: var(--primary); }
+
+.ftab-content { display: none; }
+.ftab-content.active { display: block; }
+
+.finance-card {
+ background: white;
+ border: 1.5px solid var(--border);
+ border-radius: 14px;
+ padding: 24px;
+ margin-bottom: 16px;
+}
+.finance-card h3 {
+ font-size: 1rem;
+ font-weight: 700;
+ margin-bottom: 16px;
+}
+
+/* Auslaufende Verträge */
+.expiry-badge {
+ padding: 3px 10px;
+ border-radius: 20px;
+ font-size: 0.8rem;
+ font-weight: 700;
+}
+.expiry-urgent { background:#fee2e2; color:var(--error); }
+.expiry-warning { background:#fffbeb; color:var(--warning); }
+.expiry-normal { background:#f0fdf4; color:var(--success); }
diff --git a/routes/finance.js b/routes/finance.js
new file mode 100644
index 0000000..db58ec6
--- /dev/null
+++ b/routes/finance.js
@@ -0,0 +1,243 @@
+const express = require('express');
+const router = express.Router();
+const db = require('../config/database');
+const { requireAdmin } = require('../middleware/auth');
+
+// ============================================
+// GET /admin/finance – Übersicht
+// ============================================
+router.get('/', requireAdmin, async (req, res) => {
+ try {
+ // Mahngebühr aus Einstellungen
+ const [settingRows] = await db.query("SELECT value FROM settings WHERE key_name='dunning_fee'");
+ const dunningFee = settingRows.length ? parseFloat(settingRows[0].value) : 7.50;
+
+ // Gesamtumsatz
+ const [totalRevenue] = await db.query(`
+ SELECT
+ COALESCE(SUM(CASE WHEN status='paid' THEN amount ELSE 0 END), 0) as paid_total,
+ COALESCE(SUM(CASE WHEN status='open' THEN amount ELSE 0 END), 0) as open_total,
+ COALESCE(SUM(amount), 0) as gross_total,
+ COUNT(*) as invoice_count
+ FROM invoices
+ `);
+
+ // Monatlicher Umsatz (letzte 12 Monate)
+ const [monthlyRevenue] = await db.query(`
+ SELECT
+ CONVERT(period USING utf8mb4) as period,
+ SUM(CASE WHEN status='paid' THEN amount ELSE 0 END) as paid,
+ SUM(CASE WHEN status='open' THEN amount ELSE 0 END) as open_amount,
+ SUM(amount) as total,
+ COUNT(*) as count
+ FROM invoices
+ GROUP BY CONVERT(period USING utf8mb4)
+ ORDER BY period DESC
+ LIMIT 12
+ `);
+
+ // Offene Posten
+ const [openInvoices] = await db.query(`
+ SELECT i.*, m.first_name, m.last_name, m.email, m.phone,
+ t.name as tariff_name
+ FROM invoices i
+ JOIN memberships m ON i.membership_id = m.id
+ LEFT JOIN tariffs t ON m.tariff_id = t.id
+ WHERE i.status = 'open'
+ ORDER BY i.period ASC, m.last_name ASC
+ `);
+
+ // Rückläufer
+ const [chargebacks] = await db.query(`
+ SELECT c.*, m.first_name, m.last_name, m.email
+ FROM chargebacks c
+ JOIN memberships m ON c.membership_id = m.id
+ ORDER BY c.chargeback_date DESC
+ `);
+
+ // Mahngebühren
+ const [dunnings] = await db.query(`
+ SELECT d.*, m.first_name, m.last_name, m.email
+ FROM dunning_fees d
+ JOIN memberships m ON d.membership_id = m.id
+ ORDER BY d.issued_date DESC
+ `);
+
+ // Mahngebühren Summen
+ const [dunningStats] = await db.query(`
+ SELECT
+ COALESCE(SUM(CASE WHEN status='open' THEN amount ELSE 0 END), 0) as open_total,
+ COALESCE(SUM(CASE WHEN status='paid' THEN amount ELSE 0 END), 0) as paid_total,
+ COUNT(CASE WHEN status='open' THEN 1 END) as open_count
+ FROM dunning_fees
+ `);
+
+ // Rückläufer Summen
+ const [chargebackStats] = await db.query(`
+ SELECT
+ COUNT(*) as total,
+ COUNT(CASE WHEN status='open' THEN 1 END) as open_count,
+ COALESCE(SUM(amount), 0) as total_amount
+ FROM chargebacks
+ `);
+
+ // Auslaufende Verträge (nächste 3 Monate)
+ const [expiringContracts] = await db.query(`
+ SELECT m.*, t.name as tariff_name, t.price_monthly
+ FROM memberships m
+ JOIN tariffs t ON m.tariff_id = t.id
+ WHERE m.status = 'active'
+ AND m.effective_end BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 3 MONTH)
+ ORDER BY m.effective_end ASC
+ `);
+
+ // Alle Mitglieder für Dropdowns
+ const [members] = await db.query(`
+ SELECT m.id, m.first_name, m.last_name
+ FROM memberships m WHERE m.status IN ('active','paused','inactive')
+ ORDER BY m.last_name ASC
+ `);
+
+ // Offene Rechnungen für Dropdown
+ const [openInvoicesDropdown] = await db.query(`
+ SELECT i.id, i.period, i.amount, m.first_name, m.last_name
+ FROM invoices i JOIN memberships m ON i.membership_id = m.id
+ WHERE i.status = 'open'
+ ORDER BY i.period DESC, m.last_name ASC
+ `);
+
+ res.render('admin/finance', {
+ admin: req.session.adminUser,
+ dunningFee,
+ totalRevenue: totalRevenue[0],
+ monthlyRevenue: monthlyRevenue.reverse(), // aufsteigend für Chart
+ openInvoices,
+ chargebacks,
+ dunnings,
+ dunningStats: dunningStats[0],
+ chargebackStats: chargebackStats[0],
+ expiringContracts,
+ members,
+ openInvoicesDropdown,
+ success: req.query.success || null,
+ error: req.query.error || null
+ });
+ } catch (err) {
+ console.error(err);
+ res.redirect('/admin?error=Fehler+in+der+Finanzübersicht:+' + encodeURIComponent(err.message));
+ }
+});
+
+// ============================================
+// Einstellungen speichern
+// ============================================
+router.post('/settings', requireAdmin, async (req, res) => {
+ const { dunning_fee } = req.body;
+ try {
+ await db.query(
+ "INSERT INTO settings (key_name, value, label) VALUES ('dunning_fee', ?, 'Mahngebühr (€)') ON DUPLICATE KEY UPDATE value = ?",
+ [dunning_fee, dunning_fee]
+ );
+ res.redirect('/admin/finance?success=Einstellungen+gespeichert');
+ } catch (err) {
+ res.redirect('/admin/finance?error=Fehler+beim+Speichern');
+ }
+});
+
+// ============================================
+// Rückläufer eintragen
+// ============================================
+router.post('/chargebacks/add', requireAdmin, async (req, res) => {
+ const { membership_id, invoice_id, period, amount, reason, chargeback_date, notes } = req.body;
+ try {
+ await db.query(
+ 'INSERT INTO chargebacks (membership_id, invoice_id, period, amount, reason, chargeback_date, notes) VALUES (?,?,?,?,?,?,?)',
+ [membership_id, invoice_id || null, period, amount, reason || 'SEPA Rücklastschrift', chargeback_date, notes || null]
+ );
+ // Rechnung wieder auf offen setzen
+ if (invoice_id) {
+ await db.query("UPDATE invoices SET status='open', paid_at=NULL WHERE id=?", [invoice_id]);
+ }
+ res.redirect('/admin/finance?success=Rückläufer+eingetragen');
+ } catch (err) {
+ console.error(err);
+ res.redirect('/admin/finance?error=Fehler+beim+Eintragen');
+ }
+});
+
+router.post('/chargebacks/:id/resolve', requireAdmin, async (req, res) => {
+ try {
+ await db.query("UPDATE chargebacks SET status='resolved' WHERE id=?", [req.params.id]);
+ res.redirect('/admin/finance?success=Rückläufer+als+erledigt+markiert');
+ } catch (err) {
+ res.redirect('/admin/finance?error=Fehler');
+ }
+});
+
+// CSV Import Rückläufer
+router.post('/chargebacks/import', requireAdmin, async (req, res) => {
+ const { csv_data } = req.body;
+ if (!csv_data || !csv_data.trim()) return res.redirect('/admin/finance?error=Keine+Daten+eingegeben');
+ try {
+ const lines = csv_data.trim().split('\n').filter(l => l.trim());
+ let imported = 0;
+ for (const line of lines) {
+ const cols = line.split(';').map(c => c.trim().replace(/"/g, ''));
+ if (cols.length < 3) continue;
+ const [iban, amount, date, reason] = cols;
+ const cleanIban = iban.replace(/\s/g, '');
+ // Mitglied anhand IBAN suchen
+ const [members] = await db.query(
+ "SELECT id FROM memberships WHERE REPLACE(iban,' ','') = ?", [cleanIban]
+ );
+ if (members.length === 0) continue;
+ const now = new Date();
+ const period = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}`;
+ await db.query(
+ 'INSERT INTO chargebacks (membership_id, period, amount, reason, chargeback_date) VALUES (?,?,?,?,?)',
+ [members[0].id, period, Math.abs(parseFloat(amount.replace(',','.'))), reason || 'SEPA Rücklastschrift', date || new Date().toISOString().split('T')[0]]
+ );
+ imported++;
+ }
+ res.redirect(`/admin/finance?success=${imported}+Rückläufer+importiert`);
+ } catch (err) {
+ console.error(err);
+ res.redirect('/admin/finance?error=Import+Fehler:+' + encodeURIComponent(err.message));
+ }
+});
+
+// ============================================
+// Mahngebühren
+// ============================================
+router.post('/dunning/add', requireAdmin, async (req, res) => {
+ const { membership_id, invoice_id, amount, reason, issued_date, notes } = req.body;
+ try {
+ await db.query(
+ 'INSERT INTO dunning_fees (membership_id, invoice_id, amount, reason, issued_date, notes) VALUES (?,?,?,?,?,?)',
+ [membership_id, invoice_id || null, amount, reason || 'Mahngebühr', issued_date, notes || null]
+ );
+ res.redirect('/admin/finance?success=Mahngebühr+eingetragen');
+ } catch (err) {
+ res.redirect('/admin/finance?error=Fehler+beim+Eintragen');
+ }
+});
+
+router.post('/dunning/:id/paid', requireAdmin, async (req, res) => {
+ try {
+ await db.query("UPDATE dunning_fees SET status='paid', paid_at=NOW() WHERE id=?", [req.params.id]);
+ res.redirect('/admin/finance?success=Mahngebühr+als+bezahlt+markiert');
+ } catch (err) {
+ res.redirect('/admin/finance?error=Fehler');
+ }
+});
+
+router.post('/dunning/:id/cancel', requireAdmin, async (req, res) => {
+ try {
+ await db.query("UPDATE dunning_fees SET status='cancelled' WHERE id=?", [req.params.id]);
+ res.redirect('/admin/finance?success=Mahngebühr+storniert');
+ } catch (err) {
+ res.redirect('/admin/finance?error=Fehler');
+ }
+});
+
+module.exports = router;
diff --git a/views/admin/dashboard.ejs b/views/admin/dashboard.ejs
index 9472751..f7a2300 100644
--- a/views/admin/dashboard.ejs
+++ b/views/admin/dashboard.ejs
@@ -17,6 +17,7 @@
🏷️ Kategorien
👥 Mitglieder
💶 Abrechnung
+ 📊 Finanzen
⚙️ Einstellungen