362 lines
14 KiB
JavaScript
362 lines
14 KiB
JavaScript
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;
|