const express = require('express'); const multer = require('multer'); const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 1024*1024 } }); 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 `); // Stornierte Rechnungen const [cancelledInvoices] = await db.query(` SELECT i.*, m.first_name, m.last_name, m.email, 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 = 'cancelled' ORDER BY i.created_at DESC `); // 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, cancelledInvoices, 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#chargebacks'); } }); 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, upload.single('csv_file'), async (req, res) => { // Datei hat Vorrang, sonst Textfeld let rawData = ''; if (req.file && req.file.buffer.length > 0) { rawData = req.file.buffer.toString('utf-8').replace(/\r/g, ''); } else if (req.body.csv_data && req.body.csv_data.trim()) { rawData = req.body.csv_data.trim(); } if (!rawData) return res.redirect('/admin/finance?error=Keine+Daten+eingegeben'); try { const lines = rawData.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%C3%BChr+eingetragen#dunning'); } catch (err) { res.redirect('/admin/finance?error=Fehler+beim+Eintragen#chargebacks'); } }); 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%C3%BChr+bezahlt#dunning'); } 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%C3%BChr+storniert#dunning'); } catch (err) { res.redirect('/admin/finance?error=Fehler'); } }); // ============================================ // Alle offenen Rückläufer mahnen // ============================================ router.post('/chargebacks/dunning-all', requireAdmin, async (req, res) => { const { amount, issued_date, reason } = req.body; try { const [openCBs] = await db.query( "SELECT * FROM chargebacks WHERE status = 'open'", [] ); if (openCBs.length === 0) { return res.redirect('/admin/finance?error=Keine+offenen+Rückläufer+vorhanden'); } let count = 0; for (const cb of openCBs) { await db.query( 'INSERT INTO dunning_fees (membership_id, amount, reason, issued_date, notes) VALUES (?,?,?,?,?)', [cb.membership_id, amount, reason || 'Mahngebühr Rücklastschrift', issued_date, `Automatisch aus Rückläufer vom ${new Date(cb.chargeback_date).toLocaleDateString('de-DE')}`] ); count++; } res.redirect('/admin/finance?success=Mahngeb%C3%BChren+eingetragen#chargebacks'); } catch (err) { console.error(err); res.redirect('/admin/finance?error=Fehler+beim+Eintragen#chargebacks'); } }); // Einzelne Mahngebühr über Rückläufer-ID (membership_id wird aus chargeback geholt) router.post('/dunning/add-from-chargeback', requireAdmin, async (req, res) => { const { chargeback_id, amount, issued_date, reason, notes } = req.body; try { const [cbs] = await db.query('SELECT * FROM chargebacks WHERE id = ?', [chargeback_id]); if (cbs.length === 0) return res.redirect('/admin/finance?error=Rückläufer+nicht+gefunden'); await db.query( 'INSERT INTO dunning_fees (membership_id, amount, reason, issued_date, notes) VALUES (?,?,?,?,?)', [cbs[0].membership_id, amount, reason || 'Mahngebühr Rücklastschrift', issued_date, notes || null] ); res.redirect('/admin/finance?success=Mahngeb%C3%BChr+eingetragen#dunning'); } catch (err) { res.redirect('/admin/finance?error=Fehler'); } }); // ============================================ // SEPA Nachforderung: offene Rückläufer + Mahngebühren // ============================================ router.get('/chargebacks/sepa-export', requireAdmin, async (req, res) => { try { // Offene Rückläufer const [chargebacks] = await db.query(` SELECT c.*, m.first_name, m.last_name, m.iban, m.account_holder FROM chargebacks c JOIN memberships m ON c.membership_id = m.id WHERE c.status = 'open' `); // Offene Mahngebühren const [dunnings] = await db.query(` SELECT d.*, m.first_name, m.last_name, m.iban, m.account_holder FROM dunning_fees d JOIN memberships m ON d.membership_id = m.id WHERE d.status = 'open' `); if (chargebacks.length === 0 && dunnings.length === 0) { return res.redirect('/admin/finance?error=Keine+offenen+Rückläufer+oder+Mahngebühren'); } const today = new Date().toISOString().split('T')[0]; res.setHeader('Content-Type', 'text/csv; charset=utf-8'); res.setHeader('Content-Disposition', `attachment; filename="SEPA_Nachforderung_${today}.csv"`); res.write('\uFEFF'); res.write('Name;IBAN;BIC;Betrag;Verwendungszweck;Mandatsreferenz;Mandatsdatum\n'); for (const c of chargebacks) { const name = `${c.last_name} ${c.first_name}`.replace(/;/g, ' '); const iban = (c.iban || '').replace(/\s/g, ''); const amount = Number(c.amount).toFixed(2).replace('.', ','); const ref = `PF24-RL-${String(c.id).padStart(5,'0')}`; res.write(`${name};${iban};;${amount};Nachforderung Rücklastschrift ${c.period};${ref};${today}\n`); } for (const d of dunnings) { const name = `${d.last_name} ${d.first_name}`.replace(/;/g, ' '); const iban = (d.iban || '').replace(/\s/g, ''); const amount = Number(d.amount).toFixed(2).replace('.', ','); const ref = `PF24-MG-${String(d.id).padStart(5,'0')}`; res.write(`${name};${iban};;${amount};${d.reason};${ref};${today}\n`); } res.end(); } catch (err) { console.error(err); res.redirect('/admin/finance?error=SEPA+Export+Fehler'); } }); module.exports = router;