const express = require('express'); const router = express.Router(); const db = require('../config/database'); const { requireAdmin } = require('../middleware/auth'); const PDFDocument = require('pdfkit'); // ============================================ // Hilfsfunktionen // ============================================ // Nächsten Rechnungsbetrag für ein Mitglied berechnen function calcInvoiceAmount(member, period) { // Pausiert → 0€ if (member.status === 'paused') return 0; // Erster Monat (anteilig)? const firstPeriod = member.first_payment_date ? member.first_payment_date.toISOString().substring(0, 7) : null; if (firstPeriod === period && member.first_payment_amt) { return parseFloat(member.first_payment_amt); } return parseFloat(member.price_monthly); } // Periode als lesbarer Text: "2026-04" → "April 2026" function periodLabel(period) { const [year, month] = period.split('-'); const months = ['Januar','Februar','März','April','Mai','Juni', 'Juli','August','September','Oktober','November','Dezember']; return `${months[parseInt(month) - 1]} ${year}`; } // Aktuelle Periode: "YYYY-MM" function currentPeriod() { const now = new Date(); return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; } // ============================================ // GET /admin/billing – Übersicht // ============================================ router.get('/', requireAdmin, async (req, res) => { try { const period = req.query.period || currentPeriod(); const [runs] = await db.query( 'SELECT * FROM billing_runs ORDER BY created_at DESC' ); const [invoices] = 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.period = ? ORDER BY m.last_name ASC `, [period]); const [summary] = await db.query(` SELECT COUNT(*) as total, SUM(amount) as total_amount, SUM(CASE WHEN status='open' THEN 1 ELSE 0 END) as open_count, SUM(CASE WHEN status='paid' THEN 1 ELSE 0 END) as paid_count, SUM(CASE WHEN status='open' THEN amount ELSE 0 END) as open_amount, SUM(CASE WHEN status='paid' THEN amount ELSE 0 END) as paid_amount FROM invoices WHERE period = ? `, [period]); // Vorschau: Mitglieder die noch keine Rechnung für diese Periode haben const [eligible] = await db.query(` SELECT m.*, t.price_monthly, t.name as tariff_name FROM memberships m JOIN tariffs t ON m.tariff_id = t.id WHERE m.status IN ('active','paused') AND m.contract_start <= LAST_DAY(STR_TO_DATE(CONCAT(?, '-01'), '%Y-%m-%d')) AND m.contract_end >= STR_TO_DATE(CONCAT(?, '-01'), '%Y-%m-%d') AND m.id NOT IN ( SELECT membership_id FROM invoices WHERE period = ? ) `, [period, period, period]); const preview_total = eligible.reduce((sum, m) => sum + calcInvoiceAmount(m, period), 0); res.render('admin/billing', { period, runs, invoices, summary: summary[0], eligible, preview_total, periodLabel: periodLabel(period), currentPeriod: currentPeriod(), admin: req.session.adminUser, success: req.query.success || null, error: req.query.error || null }); } catch (err) { console.error(err); res.redirect('/admin?error=Fehler+in+der+Abrechnung:+' + err.message); } }); // ============================================ // POST /admin/billing/run – Abrechnungslauf // ============================================ router.post('/run', requireAdmin, async (req, res) => { const period = req.body.period || currentPeriod(); try { // Bereits existierende Rechnungen für diesen Monat prüfen const [existing] = await db.query( 'SELECT COUNT(*) as c FROM invoices WHERE period = ?', [period] ); if (existing[0].c > 0) { return res.redirect(`/admin/billing?period=${period}&error=Abrechnungslauf+für+${period}+wurde+bereits+durchgeführt`); } // Alle aktiven/pausierten Mitglieder im Vertragszeitraum const [members] = await db.query(` SELECT m.*, t.price_monthly, t.name as tariff_name FROM memberships m JOIN tariffs t ON m.tariff_id = t.id WHERE m.status IN ('active','paused') AND m.contract_start <= LAST_DAY(STR_TO_DATE(CONCAT(?, '-01'), '%Y-%m-%d')) AND (m.contract_end IS NULL OR m.contract_end >= STR_TO_DATE(CONCAT(?, '-01'), '%Y-%m-%d')) `, [period, period]); if (members.length === 0) { return res.redirect(`/admin/billing?period=${period}&error=Keine+aktiven+Mitglieder+für+diesen+Zeitraum`); } // Billing Run erstellen const [runResult] = await db.query( 'INSERT INTO billing_runs (run_date, period, created_by) VALUES (CURDATE(), ?, ?)', [period, req.session.adminUser] ); const runId = runResult.insertId; // Rechnungen erstellen let totalAmount = 0; let invoiceCount = 0; for (const member of members) { const amount = calcInvoiceAmount(member, period); const description = `Mitgliedsbeitrag ${periodLabel(period)} – ${member.tariff_name}`; if (amount >= 0) { await db.query(` INSERT INTO invoices (billing_run_id, membership_id, period, amount, description, iban, account_holder, bank_name) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE amount = VALUES(amount) `, [runId, member.id, period, amount, description, member.iban || '', member.account_holder || '', member.bank_name || '']); totalAmount += amount; invoiceCount++; } } // Run-Summen aktualisieren await db.query( 'UPDATE billing_runs SET total_amount = ?, invoice_count = ? WHERE id = ?', [totalAmount, invoiceCount, runId] ); res.redirect(`/admin/billing?period=${period}&success=${invoiceCount}+Rechnungen+erstellt+(${totalAmount.toFixed(2).replace('.', ',')}+€+gesamt)`); } catch (err) { console.error(err); res.redirect(`/admin/billing?error=Fehler+beim+Abrechnungslauf:+` + encodeURIComponent(err.message)); } }); // ============================================ // POST /admin/billing/invoices/:id/paid – Bezahlt markieren // ============================================ router.post('/invoices/:id/paid', requireAdmin, async (req, res) => { const period = req.body.period || currentPeriod(); try { await db.query( "UPDATE invoices SET status='paid', paid_at=NOW() WHERE id=?", [req.params.id] ); res.redirect(`/admin/billing?period=${period}&success=Rechnung+als+bezahlt+markiert`); } catch (err) { res.redirect(`/admin/billing?period=${period}&error=Fehler`); } }); // POST – Alle offen als bezahlt markieren router.post('/mark-all-paid', requireAdmin, async (req, res) => { const period = req.body.period || currentPeriod(); try { const [result] = await db.query( "UPDATE invoices SET status='paid', paid_at=NOW() WHERE period=? AND status='open'", [period] ); res.redirect(`/admin/billing?period=${period}&success=${result.affectedRows}+Rechnungen+als+bezahlt+markiert`); } catch (err) { res.redirect(`/admin/billing?period=${period}&error=Fehler`); } }); // ============================================ // GET /admin/billing/export/csv – SEPA CSV // ============================================ router.get('/export/csv', requireAdmin, async (req, res) => { const period = req.query.period || currentPeriod(); try { const [invoices] = await db.query(` SELECT i.*, m.first_name, m.last_name, m.email FROM invoices i JOIN memberships m ON i.membership_id = m.id WHERE i.period = ? AND i.status = 'open' AND i.amount > 0 ORDER BY m.last_name ASC `, [period]); // Alle offenen Rechnungen dieser Periode als bezahlt markieren // IDs zuerst laden um Collation-Probleme zu umgehen const [openInvoices] = await db.query( 'SELECT id FROM invoices WHERE CONVERT(period USING utf8mb4) = CONVERT(? USING utf8mb4) AND status = ?', [period, 'open'] ); if (openInvoices.length > 0) { const ids = openInvoices.map(r => r.id); await db.query( `UPDATE invoices SET status='paid', paid_at=NOW() WHERE id IN (${ids.join(',')})`, [] ); } res.setHeader('Content-Type', 'text/csv; charset=utf-8'); res.setHeader('Content-Disposition', `attachment; filename="SEPA_${period}.csv"`); // BOM für Excel res.write('\uFEFF'); // Header res.write('Name;IBAN;BIC;Betrag;Verwendungszweck;Mandatsreferenz;Mandatsdatum\n'); for (const inv of invoices) { const name = `${inv.last_name} ${inv.first_name}`.replace(/;/g, ' '); const iban = (inv.iban || '').replace(/\s/g, ''); const amount = Number(inv.amount).toFixed(2).replace('.', ','); const purpose = `Mitgliedsbeitrag ${periodLabel(period)}`.replace(/;/g, ' '); const mandateRef = `PF24-${String(inv.membership_id).padStart(5, '0')}`; res.write(`${name};${iban};;${amount};${purpose};${mandateRef};${inv.created_at.toISOString().split('T')[0]}\n`); } res.end(); } catch (err) { console.error(err); res.redirect(`/admin/billing?error=CSV+Fehler`); } }); // ============================================ // GET /admin/billing/export/pdf/:invoiceId – Einzelrechnung PDF // ============================================ router.get('/export/pdf/:invoiceId', requireAdmin, async (req, res) => { try { const [rows] = await db.query(` SELECT i.*, m.first_name, m.last_name, m.email, m.phone, m.street, m.zip, m.city, t.name as tariff_name, t.duration_months FROM invoices i JOIN memberships m ON i.membership_id = m.id LEFT JOIN tariffs t ON m.tariff_id = t.id WHERE i.id = ? `, [req.params.invoiceId]); if (rows.length === 0) return res.status(404).send('Rechnung nicht gefunden'); const inv = rows[0]; res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Disposition', `attachment; filename="Rechnung_${inv.id}_${inv.period}.pdf"`); const doc = new PDFDocument({ margin: 60, size: 'A4' }); doc.pipe(res); // Absender doc.fontSize(10).fillColor('#666') .text('PlusFit24 UG · Moosleiten 12 · 84089 Aiglsbach', 60, 60); // Empfänger doc.fontSize(11).fillColor('#000') .text(`${inv.first_name} ${inv.last_name}`, 60, 110) .text(inv.street || '') .text(`${inv.zip} ${inv.city}`); // Rechnungstitel doc.fontSize(20).fillColor('#2d2dcc') .text('RECHNUNG', 60, 220); doc.fontSize(10).fillColor('#333') .text(`Rechnungsnummer: PF24-${String(inv.id).padStart(6, '0')}`, 60, 255) .text(`Datum: ${new Date().toLocaleDateString('de-DE')}`) .text(`Zeitraum: ${periodLabel(inv.period)}`); // Trennlinie doc.moveTo(60, 310).lineTo(535, 310).strokeColor('#ddd').stroke(); // Tabelle doc.fontSize(10).fillColor('#999') .text('Beschreibung', 60, 325) .text('Betrag', 460, 325, { align: 'right', width: 75 }); doc.moveTo(60, 340).lineTo(535, 340).strokeColor('#ddd').stroke(); doc.fontSize(11).fillColor('#000') .text(inv.description || `Mitgliedsbeitrag ${periodLabel(inv.period)}`, 60, 350) .text(`${Number(inv.amount).toFixed(2).replace('.', ',')} €`, 460, 350, { align: 'right', width: 75 }); doc.moveTo(60, 375).lineTo(535, 375).strokeColor('#ddd').stroke(); // Summe doc.fontSize(12).fillColor('#2d2dcc') .text('Gesamt:', 380, 390) .text(`${Number(inv.amount).toFixed(2).replace('.', ',')} €`, 460, 390, { align: 'right', width: 75 }); doc.fontSize(9).fillColor('#999') .text('Alle Beträge inkl. MwSt. gem. § 19 UStG (Kleinunternehmerregelung)', 60, 415); // Bankdaten doc.moveTo(60, 450).lineTo(535, 450).strokeColor('#eee').stroke(); doc.fontSize(10).fillColor('#333') .text('Bankverbindung des Mitglieds:', 60, 460) .text(`IBAN: ${inv.iban || '–'}`, 60, 475) .text(`Kontoinhaber: ${inv.account_holder || '–'}`) .text(`Geldinstitut: ${inv.bank_name || '–'}`); // Footer doc.fontSize(9).fillColor('#999') .text('PlusFit24 UG · Moosleiten 12 · 84089 Aiglsbach · Gläubiger-ID: DE1200100002549495', 60, 730, { align: 'center', width: 475 }); doc.end(); } catch (err) { console.error(err); res.status(500).send('PDF Fehler: ' + err.message); } }); module.exports = router; module.exports.currentPeriod = currentPeriod;