Vertragsverwaltung_Plusfit24/routes/billing.js

347 lines
13 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 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 10'
);
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;