Vertragsverwaltung_Plusfit24/routes/billing.js
2026-03-28 07:59:23 +00:00

472 lines
18 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;
const price = parseFloat(member.agreed_price || member.price_monthly);
const firstPeriod = member.first_payment_date
? new Date(member.first_payment_date).toISOString().substring(0, 7)
: null;
if (firstPeriod === period) {
// Startpaket nur wenn nicht erlassen (start_package_price > 0)
const startPkg = parseFloat(member.start_package_price || 0);
const daysInMonth = new Date(
new Date(member.first_payment_date).getFullYear(),
new Date(member.first_payment_date).getMonth() + 1, 0
).getDate();
const day = new Date(member.first_payment_date).getDate();
const remaining = daysInMonth - day + 1;
const partial = Math.round((price / daysInMonth) * remaining * 100) / 100;
return Math.round((partial + startPkg) * 100) / 100;
}
return price;
}
// 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 CONVERT(i.period USING utf8mb4) = CONVERT(? USING utf8mb4)
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 CONVERT(period USING utf8mb4) = CONVERT(? USING utf8mb4)
`, [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,
COALESCE(m.agreed_price, t.price_monthly) as agreed_price,
COALESCE(m.start_package_price, 0) as start_package_price
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 CONVERT(period USING utf8mb4) = CONVERT(? USING utf8mb4)
)
`, [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 CONVERT(period USING utf8mb4) = CONVERT(? USING utf8mb4)', [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,
COALESCE(m.agreed_price, t.price_monthly) as agreed_price,
COALESCE(m.start_package_price, 0) as start_package_price
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 CONVERT(period USING utf8mb4) = CONVERT(? USING utf8mb4) 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 CONVERT(i.period USING utf8mb4) = CONVERT(? USING utf8mb4) 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);
}
});
// POST Rechnung stornieren
router.post('/invoices/:id/cancel', requireAdmin, async (req, res) => {
const period = req.body.period || currentPeriod();
try {
await db.query("UPDATE invoices SET status='cancelled' WHERE id=?", [req.params.id]);
res.redirect(`/admin/billing?period=${period}&success=Rechnung+storniert`);
} catch (err) {
res.redirect(`/admin/billing?period=${period}&error=Fehler+beim+Stornieren`);
}
});
// GET Storno-PDF herunterladen
router.get('/export/storno-pdf/:invoiceId', requireAdmin, async (req, res) => {
try {
const [rows] = await db.query(`
SELECT i.*,
m.first_name, m.last_name, m.email, m.street, m.zip, m.city,
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.id = ?
`, [req.params.invoiceId]);
if (rows.length === 0) return res.status(404).send('Rechnung nicht gefunden');
const inv = rows[0];
const PDFDocument = require('pdfkit');
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="Storno_PF24-${String(inv.id).padStart(6,'0')}_${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}`);
// Roter STORNO Stempel
doc.fontSize(28).fillColor('#dc2626')
.text('STORNORECHNUNG', 60, 215);
doc.fontSize(10).fillColor('#333')
.text(`Storno-Nr.: STORNO-PF24-${String(inv.id).padStart(6,'0')}`, 60, 258)
.text(`Datum: ${new Date().toLocaleDateString('de-DE')}`)
.text(`Bezieht sich auf Rechnung: PF24-${String(inv.id).padStart(6,'0')} vom ${new Date(inv.created_at).toLocaleDateString('de-DE')}`)
.text(`Zeitraum: ${periodLabel(inv.period)}`);
doc.moveTo(60, 318).lineTo(535, 318).strokeColor('#ddd').stroke();
doc.fontSize(10).fillColor('#999')
.text('Beschreibung', 60, 330)
.text('Betrag', 460, 330, { align: 'right', width: 75 });
doc.moveTo(60, 345).lineTo(535, 345).strokeColor('#ddd').stroke();
doc.fontSize(11).fillColor('#dc2626')
.text(`Storno: ${inv.description || `Mitgliedsbeitrag ${periodLabel(inv.period)}`}`, 60, 355)
.text(`-${Number(inv.amount).toFixed(2).replace('.', ',')}`, 460, 355, { align: 'right', width: 75 });
doc.moveTo(60, 378).lineTo(535, 378).strokeColor('#ddd').stroke();
doc.fontSize(12).fillColor('#dc2626')
.text('Stornobetrag:', 360, 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);
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);
}
});
// POST Neue Rechnung aus stornierter erstellen
router.post('/invoices/:id/reissue', requireAdmin, async (req, res) => {
try {
const [rows] = await db.query('SELECT * FROM invoices WHERE id = ?', [req.params.id]);
if (rows.length === 0) return res.redirect('/admin/billing?error=Rechnung+nicht+gefunden');
const orig = rows[0];
// Neue Rechnung mit gleichen Daten aber neuer ID und Status open
const [result] = await db.query(`
INSERT INTO invoices (billing_run_id, membership_id, period, amount, description, iban, account_holder, bank_name, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'open')
`, [
orig.billing_run_id, orig.membership_id,
orig.period,
orig.amount,
orig.description ? orig.description + ' (Neuausstellung)' : 'Neuausstellung',
orig.iban, orig.account_holder, orig.bank_name
]);
const newPeriod = orig.period;
res.redirect(`/admin/billing?period=${newPeriod}&success=Neue+Rechnung+PF24-${String(result.insertId).padStart(6,'0')}+erstellt`);
} catch (err) {
console.error(err);
res.redirect('/admin/billing?error=Fehler+bei+Neuausstellung');
}
});
module.exports = router;
module.exports.currentPeriod = currentPeriod;