472 lines
18 KiB
JavaScript
472 lines
18 KiB
JavaScript
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;
|