Vertragsverwaltung_Plusfit24/routes/finance.js
2026-03-27 13:47:51 +00:00

362 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;