diff --git a/app.js b/app.js index cf2da39..502e039 100644 --- a/app.js +++ b/app.js @@ -33,10 +33,13 @@ app.use(session({ const indexRouter = require('./routes/index'); const adminRouter = require('./routes/admin'); const apiRouter = require('./routes/api'); +const billingRouter = require('./routes/billing'); +const cron = require('node-cron'); app.use('/', indexRouter); app.use('/admin', adminRouter); app.use('/api', apiRouter); +app.use('/admin/billing', billingRouter); // 404 Handler app.use((req, res) => { @@ -66,6 +69,45 @@ async function initAdmin() { } } +// Auto-Abrechnungslauf jeden 1. des Monats um 06:00 Uhr +cron.schedule('0 6 1 * *', async () => { + const { currentPeriod } = require('./routes/billing'); + const period = currentPeriod(); + console.log(`⏰ Auto-Abrechnungslauf gestartet für ${period}`); + try { + const [existing] = await db.query('SELECT COUNT(*) as c FROM invoices WHERE period = ?', [period]); + if (existing[0].c > 0) { + console.log(`⏭ Abrechnungslauf für ${period} bereits vorhanden – übersprungen`); + return; + } + const [members] = await db.query(` + SELECT m.*, t.price_monthly 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]); + const [runResult] = await db.query( + 'INSERT INTO billing_runs (run_date, period, created_by) VALUES (CURDATE(), ?, ?)', + [period, 'system-auto'] + ); + let total = 0, count = 0; + for (const m of members) { + const firstPeriod = m.first_payment_date ? m.first_payment_date.toISOString().substring(0,7) : null; + const amount = firstPeriod === period && m.first_payment_amt ? parseFloat(m.first_payment_amt) : parseFloat(m.price_monthly); + await db.query( + 'INSERT IGNORE INTO invoices (billing_run_id, membership_id, period, amount, description, iban, account_holder, bank_name) VALUES (?,?,?,?,?,?,?,?)', + [runResult.insertId, m.id, period, amount, `Mitgliedsbeitrag ${period}`, m.iban||'', m.account_holder||'', m.bank_name||''] + ); + total += amount; count++; + } + await db.query('UPDATE billing_runs SET total_amount=?, invoice_count=? WHERE id=?', [total, count, runResult.insertId]); + console.log(`✅ Auto-Abrechnungslauf abgeschlossen: ${count} Rechnungen, ${total.toFixed(2)} €`); + } catch (err) { + console.error('❌ Auto-Abrechnungslauf Fehler:', err.message); + } +}); + const PORT = process.env.PORT || 3100; app.listen(PORT, async () => { console.log(`🚀 PlusFit24 Server läuft auf Port ${PORT}`); diff --git a/package.json b/package.json index 1b5db89..a652886 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,9 @@ "mysql2": "^3.6.0", "bcryptjs": "^2.4.3", "dotenv": "^16.3.1", - "dns": "^0.2.2" + "dns": "^0.2.2", + "pdfkit": "^0.14.0", + "node-cron": "^3.0.3" }, "devDependencies": { "nodemon": "^3.0.1" diff --git a/public/css/style.css b/public/css/style.css index 443e9b3..15cb8c7 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1090,3 +1090,57 @@ body:not(.admin-body) > * { .auszeit-form-row .karte-field { min-width: 130px; } + +/* ================================================ + ABRECHNUNG / BILLING + ================================================ */ +.billing-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; + flex-wrap: wrap; + gap: 12px; +} +.billing-header h1 { font-size: 1.8rem; font-weight: 800; margin: 0; } +.period-form { display: flex; gap: 8px; align-items: center; } +.period-input { padding: 8px 12px; border: 1.5px solid var(--border); border-radius: 8px; + font-family: 'Outfit', sans-serif; font-size: 0.92rem; outline: none; } +.period-input:focus { border-color: var(--primary); } +.billing-period-title { + font-size: 1.3rem; font-weight: 700; color: var(--primary); + margin-bottom: 20px; +} +.billing-actions { margin-bottom: 24px; } +.billing-preview-box { + display: flex; align-items: center; justify-content: space-between; + background: #eff6ff; border: 1.5px solid #bfdbfe; + border-radius: 14px; padding: 16px 24px; gap: 16px; flex-wrap: wrap; +} +.preview-info { display: flex; flex-direction: column; gap: 4px; } +.preview-info strong { font-size: 1rem; } +.preview-info span { font-size: 0.88rem; color: var(--text-muted); } +.billing-run-done { display: flex; align-items: center; justify-content: flex-end; gap: 12px; } +.billing-action-btns { display: flex; gap: 10px; flex-wrap: wrap; } + +.invoice-nr { font-family: monospace; font-size: 0.82rem; color: var(--text-muted); } +.amount-cell { font-size: 1rem; } +.text-muted { color: var(--text-muted); } + +.invoice-status { font-size: 0.82rem; font-weight: 700; padding: 3px 8px; border-radius: 12px; } +.invoice-status.open { background: #fee2e2; color: var(--error); } +.invoice-status.paid { background: #dcfce7; color: var(--success); } +.invoice-status.cancelled { background: #f3f4f6; color: var(--text-muted); } + +.invoice-actions { display: flex; gap: 6px; align-items: center; } + +.preview-table-wrap { margin-bottom: 24px; } +.preview-title { + font-size: 1rem; font-weight: 700; color: var(--text-muted); + margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.5px; +} +.runs-section { margin-top: 32px; } +.runs-section h3 { + font-size: 1rem; font-weight: 700; color: var(--text-muted); + margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.5px; +} diff --git a/routes/billing.js b/routes/billing.js new file mode 100644 index 0000000..57275b9 --- /dev/null +++ b/routes/billing.js @@ -0,0 +1,332 @@ +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 LIMIT 12' + ); + + 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]); + + 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; diff --git a/views/admin/billing.ejs b/views/admin/billing.ejs new file mode 100644 index 0000000..d014def --- /dev/null +++ b/views/admin/billing.ejs @@ -0,0 +1,229 @@ + + + + + + PlusFit24 – Abrechnung + + + + +
+ + + +
+ + +
+

💶 Abrechnung

+
+ + +
+
+ + <% if (success) { %>
<%= success %>
<% } %> + <% if (error) { %>
<%= error %>
<% } %> + + +

<%= periodLabel %>

+ + +
+
+
<%= summary.total || 0 %>
+
Rechnungen gesamt
+
+
+
<%= summary.open_count || 0 %>
+
Offen
+
+
+
<%= summary.paid_count || 0 %>
+
Bezahlt
+
+
+
<%= summary.total_amount ? Number(summary.total_amount).toFixed(2).replace('.', ',') + ' €' : '0,00 €' %>
+
Gesamtbetrag
+
+
+
<%= summary.open_amount ? Number(summary.open_amount).toFixed(2).replace('.', ',') + ' €' : '0,00 €' %>
+
Noch offen
+
+
+
<%= summary.paid_amount ? Number(summary.paid_amount).toFixed(2).replace('.', ',') + ' €' : '0,00 €' %>
+
Bereits bezahlt
+
+
+ + +
+ <% if (invoices.length === 0) { %> + +
+
+ Bereit für Abrechnungslauf <%= periodLabel %> + <%= eligible.length %> Mitglieder · Voraussichtlich <%= Number(preview_total).toFixed(2).replace('.', ',') %> € +
+
+ + +
+
+ <% } else { %> + +
+
+ + 📥 SEPA CSV exportieren + + <% if (summary.open_count > 0) { %> +
+ + +
+ <% } %> +
+
+ <% } %> +
+ + + <% if (invoices.length > 0) { %> +
+ + + + + + + + + + + + + + <% invoices.forEach(inv => { %> + + + + + + + + + + <% }) %> + +
Nr.MitgliedTarifBetragIBANStatusAktionen
PF24-<%= String(inv.id).padStart(6, '0') %> + <%= inv.last_name %>, <%= inv.first_name %>
+ <%= inv.email %> +
<%= inv.tariff_name || '–' %> + <%= Number(inv.amount).toFixed(2).replace('.', ',') %> € + + <%= inv.iban ? inv.iban.replace(/(.{4})/g, '$1 ').trim() : '–' %> + + + <%= inv.status === 'paid' ? '✅ Bezahlt' : inv.status === 'open' ? '🔴 Offen' : '❌ Storniert' %> + + <% if (inv.paid_at) { %> +
<%= new Date(inv.paid_at).toLocaleDateString('de-DE') %> + <% } %> +
+
+ + 📄 PDF + + <% if (inv.status === 'open') { %> +
+ + +
+ <% } %> +
+
+
+ <% } else if (eligible.length > 0) { %> + +
+

Vorschau – wird abgerechnet

+ + + + + + + + + + <% eligible.forEach(m => { %> + + + + + + <% }) %> + +
MitgliedTarifVoraussichtlicher Betrag
<%= m.last_name %>, <%= m.first_name %><%= m.tariff_name %><%= Number(m.price_monthly).toFixed(2).replace('.', ',') %> €
+
+ <% } else { %> +
Keine Mitglieder für diesen Zeitraum.
+ <% } %> + + + <% if (runs.length > 0) { %> +
+

Letzte Abrechnungsläufe

+ + + + + + + + + + + + + <% runs.forEach(run => { %> + + + + + + + + + <% }) %> + +
PeriodeDatumRechnungenGesamtbetragErstellt vonAktion
<%= run.period %><%= new Date(run.created_at).toLocaleDateString('de-DE') %><%= run.invoice_count %><%= Number(run.total_amount).toFixed(2).replace('.', ',') %> €<%= run.created_by || '–' %> + + Anzeigen + +
+
+ <% } %> + +
+
+ + diff --git a/views/admin/dashboard.ejs b/views/admin/dashboard.ejs index dfcc00c..9472751 100644 --- a/views/admin/dashboard.ejs +++ b/views/admin/dashboard.ejs @@ -16,6 +16,7 @@ 📋 Tarife 🏷️ Kategorien 👥 Mitglieder + 💶 Abrechnung ⚙️ Einstellungen