verlängerung

This commit is contained in:
cay 2026-03-27 14:56:59 +00:00
parent a6151c6da8
commit 7cee8ac281
8 changed files with 453 additions and 2 deletions

54
app.js
View File

@ -35,6 +35,7 @@ const adminRouter = require('./routes/admin');
const apiRouter = require('./routes/api');
const billingRouter = require('./routes/billing');
const financeRouter = require('./routes/finance');
const renewalRouter = require('./routes/renewal');
const cron = require('node-cron');
app.use('/', indexRouter);
@ -42,6 +43,7 @@ app.use('/admin', adminRouter);
app.use('/api', apiRouter);
app.use('/admin/billing', billingRouter);
app.use('/admin/finance', financeRouter);
app.use('/', renewalRouter);
// 404 Handler
app.use((req, res) => {
@ -71,6 +73,58 @@ async function initAdmin() {
}
}
// Verlängerungs-E-Mails: täglich um 08:00 Uhr prüfen (60 Tage vorher)
cron.schedule('0 8 * * *', async () => {
console.log('⏰ Prüfe auslaufende Verträge (60 Tage)...');
try {
const [expiring] = await db.query(`
SELECT m.* FROM memberships m
WHERE m.status = 'active'
AND m.effective_end BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 61 DAY)
AND m.id NOT IN (
SELECT membership_id FROM renewal_requests
WHERE status IN ('pending','completed')
AND sent_at >= DATE_SUB(NOW(), INTERVAL 70 DAY)
)
`);
if (expiring.length > 0) {
const { renewalEmailHtml } = require('./routes/renewal');
const mailer = require('./config/mailer');
const crypto = require('crypto');
const [tariffs] = await db.query('SELECT * FROM tariffs WHERE active = 1 ORDER BY price_monthly ASC');
for (const member of expiring) {
try {
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
await db.query(
'INSERT INTO renewal_requests (membership_id, token, expires_at) VALUES (?,?,?)',
[member.id, token, expiresAt]
);
const baseUrl = process.env.APP_URL || 'https://plusfit24.software-joksch.com';
const link = baseUrl + '/renew/' + token;
await mailer.sendMail({
from: process.env.MAIL_FROM,
to: member.email,
subject: `Deine PlusFit24 Mitgliedschaft läuft bald ab`,
html: renewalEmailHtml(member, tariffs, link, member.effective_end || member.contract_end)
});
await db.query(
'INSERT INTO email_log (membership_id, type, recipient, subject, status) VALUES (?,?,?,?,?)',
[member.id, 'renewal_auto', member.email, 'Mitgliedschaft läuft ab', 'sent']
);
console.log('📧 Verlängerungs-E-Mail gesendet an:', member.email);
} catch (err) {
console.error('❌ E-Mail Fehler für', member.email, ':', err.message);
}
}
} else {
console.log('✅ Keine auslaufenden Verträge heute.');
}
} catch (err) {
console.error('❌ Cron Fehler:', err.message);
}
});
// Auto-Abrechnungslauf jeden 1. des Monats um 06:00 Uhr
cron.schedule('0 6 1 * *', async () => {
const { currentPeriod } = require('./routes/billing');

20
config/mailer.js Normal file
View File

@ -0,0 +1,20 @@
const nodemailer = require('nodemailer');
const transporter = nodemailer.createTransport({
host: process.env.MAIL_HOST || 'smtp.ionos.de',
port: parseInt(process.env.MAIL_PORT) || 587,
secure: process.env.MAIL_SECURE === 'true',
auth: {
user: process.env.MAIL_USER,
pass: process.env.MAIL_PASSWORD
},
tls: { rejectUnauthorized: false }
});
// Verbindung testen beim Start
transporter.verify((err) => {
if (err) console.error('❌ E-Mail Verbindung fehlgeschlagen:', err.message);
else console.log('✅ E-Mail Server verbunden:', process.env.MAIL_HOST);
});
module.exports = transporter;

View File

@ -17,7 +17,8 @@
"dns": "^0.2.2",
"pdfkit": "^0.14.0",
"node-cron": "^3.0.3",
"multer": "^1.4.5-lts.1"
"multer": "^1.4.5-lts.1",
"nodemailer": "^6.9.7"
},
"devDependencies": {
"nodemon": "^3.0.1"

248
routes/renewal.js Normal file
View File

@ -0,0 +1,248 @@
const express = require('express');
const router = express.Router();
const crypto = require('crypto');
const db = require('../config/database');
const mailer = require('../config/mailer');
const { requireAdmin } = require('../middleware/auth');
// ============================================
// E-Mail Templates
// ============================================
function renewalEmailHtml(member, tariffs, renewalLink, expiryDate) {
const tarifList = tariffs.map(t => `
<tr>
<td style="padding:8px 12px;border-bottom:1px solid #eee">${t.name}</td>
<td style="padding:8px 12px;border-bottom:1px solid #eee;text-align:right">
<strong>${Number(t.price_monthly).toFixed(2).replace('.', ',')} /Monat</strong>
</td>
<td style="padding:8px 12px;border-bottom:1px solid #eee">${t.duration_months} Monate</td>
</tr>`).join('');
return `<!DOCTYPE html>
<html lang="de">
<head><meta charset="UTF-8"></head>
<body style="font-family:Outfit,Arial,sans-serif;background:#f8f9ff;margin:0;padding:20px">
<div style="max-width:600px;margin:0 auto;background:white;border-radius:16px;overflow:hidden;box-shadow:0 2px 16px rgba(0,0,0,0.08)">
<div style="background:#2d2dcc;padding:32px;text-align:center">
<h1 style="color:white;margin:0;font-size:1.8rem">Plusfit<span style="color:#a5b4fc">24</span></h1>
<p style="color:#c7d2fe;margin:8px 0 0">Deine Mitgliedschaft läuft bald ab</p>
</div>
<div style="padding:32px">
<p>Hallo ${member.first_name} ${member.last_name},</p>
<p>deine Mitgliedschaft bei PlusFit24 läuft am <strong>${new Date(expiryDate).toLocaleDateString('de-DE')}</strong> aus.</p>
<p>Wir würden uns freuen, dich weiterhin in unserem Studio begrüßen zu dürfen! Wähle jetzt deinen neuen Tarif:</p>
<table style="width:100%;border-collapse:collapse;margin:20px 0;border:1px solid #eee;border-radius:8px;overflow:hidden">
<thead>
<tr style="background:#f0f0ff">
<th style="padding:10px 12px;text-align:left;font-size:0.8rem;text-transform:uppercase;letter-spacing:0.5px">Tarif</th>
<th style="padding:10px 12px;text-align:right;font-size:0.8rem;text-transform:uppercase;letter-spacing:0.5px">Preis</th>
<th style="padding:10px 12px;font-size:0.8rem;text-transform:uppercase;letter-spacing:0.5px">Laufzeit</th>
</tr>
</thead>
<tbody>${tarifList}</tbody>
</table>
<div style="text-align:center;margin:28px 0">
<a href="${renewalLink}" style="display:inline-block;background:#2d2dcc;color:white;padding:14px 32px;border-radius:10px;text-decoration:none;font-weight:700;font-size:1rem">
Jetzt Mitgliedschaft verlängern
</a>
</div>
<p style="color:#6b7280;font-size:0.85rem">
Dieser Link ist 30 Tage gültig. Falls du Fragen hast, melde dich gerne bei uns.<br>
<strong>PlusFit24 UG</strong> · Moosleiten 12 · 84089 Aiglsbach
</p>
</div>
</div>
</body></html>`;
}
// ============================================
// Öffentliche Verlängerungsseite (für Mitglieder)
// ============================================
router.get('/renew/:token', async (req, res) => {
try {
const [rows] = await db.query(`
SELECT r.*, m.first_name, m.last_name, m.email,
m.agreed_price, m.tariff_id
FROM renewal_requests r
JOIN memberships m ON r.membership_id = m.id
WHERE r.token = ? AND r.status = 'pending' AND r.expires_at > NOW()
`, [req.params.token]);
if (rows.length === 0) {
return res.render('renewal-expired');
}
const [tariffs] = await db.query(
'SELECT * FROM tariffs WHERE active = 1 ORDER BY price_monthly ASC'
);
res.render('renewal', {
request: rows[0],
tariffs,
error: null,
success: null
});
} catch (err) {
console.error(err);
res.render('error', { message: 'Fehler beim Laden der Seite.' });
}
});
router.post('/renew/:token', async (req, res) => {
const { tariff_id } = req.body;
try {
const [rows] = await db.query(`
SELECT r.*, m.first_name, m.last_name, m.email,
m.contract_end, m.effective_end
FROM renewal_requests r
JOIN memberships m ON r.membership_id = m.id
WHERE r.token = ? AND r.status = 'pending' AND r.expires_at > NOW()
`, [req.params.token]);
if (rows.length === 0) return res.render('renewal-expired');
const request = rows[0];
const [tariffs] = await db.query('SELECT * FROM tariffs WHERE id = ? AND active = 1', [tariff_id]);
if (tariffs.length === 0) {
return res.render('renewal', { request, tariffs: [], error: 'Ungültiger Tarif.', success: null });
}
const tariff = tariffs[0];
// Neues Vertragsende berechnen
const startDate = new Date(request.effective_end || request.contract_end || new Date());
const newEnd = new Date(startDate);
newEnd.setMonth(newEnd.getMonth() + tariff.duration_months);
await db.query(`
UPDATE memberships SET
tariff_id = ?,
agreed_price = ?,
agreed_duration = ?,
contract_end = ?,
effective_end = ?,
status = 'active'
WHERE id = ?
`, [
tariff.id, tariff.price_monthly, tariff.duration_months,
newEnd, newEnd, request.membership_id
]);
await db.query(
"UPDATE renewal_requests SET status='completed', completed_at=NOW() WHERE id=?",
[request.id]
);
res.render('renewal', {
request, tariffs: [tariff],
error: null,
success: `Deine Mitgliedschaft wurde erfolgreich verlängert! Neuer Tarif: ${tariff.name} bis ${newEnd.toLocaleDateString('de-DE')}`
});
} catch (err) {
console.error(err);
res.render('error', { message: 'Fehler bei der Verlängerung.' });
}
});
// ============================================
// Admin: Verlängerungs-E-Mail manuell senden
// ============================================
router.post('/admin/send-renewal/:memberId', requireAdmin, async (req, res) => {
const memberId = req.params.memberId;
const backUrl = req.headers.referer || '/admin/finance';
try {
const [members] = await db.query(`
SELECT m.*, t.name as tariff_name
FROM memberships m
LEFT JOIN tariffs t ON m.tariff_id = t.id
WHERE m.id = ?
`, [memberId]);
if (members.length === 0) return res.redirect(backUrl + '?error=Mitglied+nicht+gefunden');
const member = members[0];
// Alten Pending-Request invalidieren
await db.query(
"UPDATE renewal_requests SET status='expired' WHERE membership_id=? AND status='pending'",
[memberId]
);
// Neuen Token generieren
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 Tage
await db.query(
'INSERT INTO renewal_requests (membership_id, token, expires_at) VALUES (?, ?, ?)',
[memberId, token, expiresAt]
);
// Aktive Tarife laden
const [tariffs] = await db.query('SELECT * FROM tariffs WHERE active = 1 ORDER BY price_monthly ASC');
const baseUrl = `${req.protocol}://${req.get('host')}`;
const renewalLink = `${baseUrl}/renew/${token}`;
const expiryDate = member.effective_end || member.contract_end;
// E-Mail senden
await mailer.sendMail({
from: process.env.MAIL_FROM,
to: member.email,
subject: `Deine PlusFit24 Mitgliedschaft läuft am ${new Date(expiryDate).toLocaleDateString('de-DE')} ab`,
html: renewalEmailHtml(member, tariffs, renewalLink, expiryDate)
});
// Log
await db.query(
'INSERT INTO email_log (membership_id, type, recipient, subject, status) VALUES (?,?,?,?,?)',
[memberId, 'renewal', member.email, 'Mitgliedschaft läuft ab', 'sent']
);
res.redirect(backUrl + '?success=Verlängerungs-E-Mail+an+' + encodeURIComponent(member.email) + '+gesendet');
} catch (err) {
console.error(err);
// Log failure
await db.query(
'INSERT INTO email_log (membership_id, type, recipient, subject, status) VALUES (?,?,?,?,?)',
[memberId, 'renewal', '', 'Verlängerung', 'failed']
).catch(() => {});
res.redirect(backUrl + '?error=E-Mail+Fehler:+' + encodeURIComponent(err.message));
}
});
// Admin: Verlängerung manuell durchführen
router.post('/admin/renew-manual/:memberId', requireAdmin, async (req, res) => {
const { new_tariff_id } = req.body;
const memberId = req.params.memberId;
try {
const [tariffs] = await db.query('SELECT * FROM tariffs WHERE id = ?', [new_tariff_id]);
if (tariffs.length === 0) return res.redirect(`/admin/members/${memberId}?error=Tarif+nicht+gefunden`);
const tariff = tariffs[0];
const [members] = await db.query('SELECT * FROM memberships WHERE id = ?', [memberId]);
const member = members[0];
const startDate = new Date(member.effective_end || member.contract_end || new Date());
const newEnd = new Date(startDate);
newEnd.setMonth(newEnd.getMonth() + tariff.duration_months);
await db.query(`
UPDATE memberships SET
tariff_id = ?,
agreed_price = ?,
agreed_duration = ?,
contract_end = ?,
effective_end = ?,
status = 'active'
WHERE id = ?
`, [tariff.id, tariff.price_monthly, tariff.duration_months, newEnd, newEnd, memberId]);
res.redirect(`/admin/members/${memberId}?success=Vertrag+verlängert+bis+${newEnd.toLocaleDateString('de-DE')}`);
} catch (err) {
console.error(err);
res.redirect(`/admin/members/${memberId}?error=Fehler+bei+Verlängerung`);
}
});
module.exports = router;
module.exports.renewalEmailHtml = renewalEmailHtml;

View File

@ -275,7 +275,12 @@
</td>
<td><%= Number(m.price_monthly).toFixed(2).replace('.', ',') %> €</td>
<td>
<a href="/admin/members/<%= m.id %>" class="btn btn-sm btn-outline">Zur Karteikarte</a>
<div style="display:flex;gap:6px">
<a href="/admin/members/<%= m.id %>" class="btn btn-sm btn-outline">Karteikarte</a>
<form method="POST" action="/admin/send-renewal/<%= m.id %>" style="display:inline">
<button type="submit" class="btn btn-sm btn-primary" title="Verlängerungs-E-Mail senden">📧 E-Mail</button>
</form>
</div>
</td>
</tr>
<% }) %>

View File

@ -455,6 +455,41 @@
</div>
</div>
<!-- ===== KARTE: Vertragsverlängerung ===== -->
<div class="karte karte-full">
<div class="karte-header">
<span class="karte-icon">🔄</span>
<h3>Vertragsverlängerung</h3>
</div>
<div class="karte-body">
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:flex-end">
<!-- Manuell verlängern -->
<form method="POST" action="/admin/renew-manual/<%= member.id %>"
style="display:flex;gap:10px;align-items:flex-end;flex:1;min-width:300px"
onsubmit="return confirm('Vertrag manuell verlängern?')">
<div class="karte-field" style="flex:1">
<label>Neuer Tarif</label>
<select name="new_tariff_id" class="karte-input" style="border:1.5px solid var(--border);background:white">
<% tariffs.forEach(t => { %>
<option value="<%= t.id %>"><%= t.name %> <%= Number(t.price_monthly).toFixed(2) %>€/Monat</option>
<% }) %>
</select>
</div>
<button type="submit" class="btn btn-primary btn-sm" style="white-space:nowrap">
🔄 Manuell verlängern
</button>
</form>
<!-- E-Mail senden -->
<form method="POST" action="/admin/send-renewal/<%= member.id %>">
<button type="submit" class="btn btn-outline btn-sm" style="white-space:nowrap">
📧 Verlängerungs-E-Mail senden
</button>
</form>
</div>
</div>
</div>
</div><!-- end karteikarte-grid -->
</form>

18
views/renewal-expired.ejs Normal file
View File

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="de"><head><meta charset="UTF-8">
<title>PlusFit24 Link abgelaufen</title>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<header class="site-header"><div class="header-inner"><div class="logo">Plusfit<span>24</span></div></div></header>
<main class="success-main">
<div class="success-card">
<div class="success-icon">⏰</div>
<h1>Link abgelaufen</h1>
<p>Dieser Verlängerungslink ist nicht mehr gültig.</p>
<p class="success-sub">Bitte kontaktiere uns direkt im Studio oder ruf uns an.</p>
<a href="/" class="btn btn-primary">Zur Startseite</a>
</div>
</main>
</body></html>

70
views/renewal.ejs Normal file
View File

@ -0,0 +1,70 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PlusFit24 Mitgliedschaft verlängern</title>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<header class="site-header">
<div class="header-inner">
<div class="logo">Plusfit<span>24</span></div>
</div>
</header>
<main class="signup-main">
<div class="signup-container" style="max-width:680px">
<h2 class="step-title bold" style="margin-bottom:8px">Mitgliedschaft verlängern</h2>
<p class="step-subtitle">Hallo <%= request.first_name %>! Wähle deinen neuen Tarif.</p>
<% if (success) { %>
<div class="alert alert-success" style="margin-bottom:24px"><%= success %></div>
<a href="/" class="btn btn-primary">Zurück zur Startseite</a>
<% } else { %>
<% if (error) { %><div class="alert alert-error"><%= error %></div><% } %>
<form method="POST" action="/renew/<%= request.token %>">
<div class="tarif-grid" style="margin-bottom:24px">
<% tariffs.forEach(tariff => { %>
<label class="tarif-card renewal-tarif-card">
<input type="radio" name="tariff_id" value="<%= tariff.id %>" required
style="display:none" class="tarif-radio">
<div class="tarif-card-inner">
<div class="tarif-badge">
<span class="tarif-icon">◑</span>
<span><%= tariff.name %></span>
</div>
<h2 class="tarif-name"><%= tariff.name %></h2>
<div class="tarif-feature">
<span class="feature-icon">📦</span>
<span>Startpaket <%= Number(tariff.start_package_price).toFixed(2).replace('.', ',') %>€ einmalig</span>
</div>
<div class="tarif-price">
<span class="price-amount"><%= Number(tariff.price_monthly).toFixed(2).replace('.', ',') %>€</span>
<span class="price-period">/Monat</span>
</div>
</div>
</label>
<% }) %>
</div>
<button type="submit" class="btn btn-primary btn-full">Mitgliedschaft verlängern →</button>
</form>
<% } %>
</div>
</main>
<footer class="site-footer">
<p>© 2024 PlusFit24 UG</p>
</footer>
<script>
// Tarif-Karte anklicken → Radio aktivieren
document.querySelectorAll('.renewal-tarif-card').forEach(card => {
card.addEventListener('click', () => {
document.querySelectorAll('.renewal-tarif-card').forEach(c => c.style.borderColor = '');
card.style.borderColor = '#2d2dcc';
card.querySelector('input').checked = true;
});
});
</script>
</body>
</html>