dfhe
This commit is contained in:
parent
6574b5f6d9
commit
4ce99b36bc
@ -205,7 +205,7 @@ router.post('/members/:id/update', requireAdmin, async (req, res) => {
|
|||||||
salutation, title, first_name, last_name, birth_date,
|
salutation, title, first_name, last_name, birth_date,
|
||||||
email, phone, street, address_addition, zip, city,
|
email, phone, street, address_addition, zip, city,
|
||||||
bank_name, account_holder, iban, tariff_id, status,
|
bank_name, account_holder, iban, tariff_id, status,
|
||||||
agreed_price, agreed_duration
|
agreed_price, agreed_duration, start_package_price
|
||||||
} = req.body;
|
} = req.body;
|
||||||
try {
|
try {
|
||||||
await db.query(`
|
await db.query(`
|
||||||
|
|||||||
@ -1,28 +1,28 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const dns = require('dns').promises;
|
const dns = require('dns').promises;
|
||||||
const db = require('../config/database');
|
const db = require('../config/database');
|
||||||
|
|
||||||
// Email Validierung via DNS MX-Record Check
|
// ============================================
|
||||||
|
// E-Mail Validierung via DNS MX-Record
|
||||||
|
// ============================================
|
||||||
async function verifyEmailDomain(email) {
|
async function verifyEmailDomain(email) {
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
if (!emailRegex.test(email)) return { valid: false, reason: 'Ungültiges E-Mail-Format' };
|
if (!emailRegex.test(email)) return { valid: false, reason: 'Ungültiges E-Mail-Format' };
|
||||||
|
|
||||||
const domain = email.split('@')[1];
|
const domain = email.split('@')[1];
|
||||||
try {
|
try {
|
||||||
const records = await dns.resolveMx(domain);
|
const records = await dns.resolveMx(domain);
|
||||||
if (records && records.length > 0) {
|
if (records && records.length > 0) return { valid: true };
|
||||||
return { valid: true };
|
|
||||||
}
|
|
||||||
return { valid: false, reason: 'Domain hat keine E-Mail-Server (MX-Records fehlen)' };
|
return { valid: false, reason: 'Domain hat keine E-Mail-Server (MX-Records fehlen)' };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { valid: false, reason: 'E-Mail-Domain konnte nicht verifiziert werden' };
|
return { valid: false, reason: 'E-Mail-Domain konnte nicht verifiziert werden' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
// Server-seitige IBAN Prüfung (Modulo-97)
|
// IBAN Validierung (Modulo-97)
|
||||||
|
// ============================================
|
||||||
const IBAN_LENGTHS = {
|
const IBAN_LENGTHS = {
|
||||||
AL:28,AD:24,AT:20,AZ:28,BH:22,BE:16,BA:20,BR:29,BG:22,CR:22,HR:21,
|
AL:28,AD:24,AT:20,AZ:28,BH:22,BE:16,BA:20,BR:29,BG:22,CR:22,HR:21,
|
||||||
CY:28,CZ:24,DK:18,DO:28,EE:20,FO:18,FI:18,FR:27,GE:22,DE:22,GI:23,
|
CY:28,CZ:24,DK:18,DO:28,EE:20,FO:18,FI:18,FR:27,GE:22,DE:22,GI:23,
|
||||||
@ -32,7 +32,7 @@ const IBAN_LENGTHS = {
|
|||||||
SK:24,SI:19,ES:24,SE:24,CH:21,TN:24,TR:26,AE:23,GB:22,VG:24
|
SK:24,SI:19,ES:24,SE:24,CH:21,TN:24,TR:26,AE:23,GB:22,VG:24
|
||||||
};
|
};
|
||||||
function validateIBANServer(iban) {
|
function validateIBANServer(iban) {
|
||||||
if (!iban) return { valid: true }; // Optional – kein IBAN = ok
|
if (!iban) return { valid: true };
|
||||||
const clean = iban.replace(/\s/g, '').toUpperCase();
|
const clean = iban.replace(/\s/g, '').toUpperCase();
|
||||||
if (clean.length < 5) return { valid: false, error: 'IBAN zu kurz' };
|
if (clean.length < 5) return { valid: false, error: 'IBAN zu kurz' };
|
||||||
const country = clean.substring(0, 2);
|
const country = clean.substring(0, 2);
|
||||||
@ -57,7 +57,6 @@ function validateIBANServer(iban) {
|
|||||||
router.post('/verify-email', async (req, res) => {
|
router.post('/verify-email', async (req, res) => {
|
||||||
const { email } = req.body;
|
const { email } = req.body;
|
||||||
if (!email) return res.json({ valid: false, reason: 'Keine E-Mail angegeben' });
|
if (!email) return res.json({ valid: false, reason: 'Keine E-Mail angegeben' });
|
||||||
|
|
||||||
const result = await verifyEmailDomain(email);
|
const result = await verifyEmailDomain(email);
|
||||||
res.json(result);
|
res.json(result);
|
||||||
});
|
});
|
||||||
@ -73,13 +72,21 @@ router.post('/submit-membership', async (req, res) => {
|
|||||||
guardian_consent
|
guardian_consent
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
|
// Pflichtfelder prüfen
|
||||||
|
if (!tariff_id || !first_name || !last_name || !birth_date || !email || !street || !zip || !city) {
|
||||||
|
return res.json({ success: false, error: 'Bitte alle Pflichtfelder ausfüllen.' });
|
||||||
|
}
|
||||||
|
if (!agb_accepted || !datenschutz_accepted || !data_correct) {
|
||||||
|
return res.json({ success: false, error: 'Bitte alle Einverständniserklärungen bestätigen.' });
|
||||||
|
}
|
||||||
|
|
||||||
// E-Mail validieren
|
// E-Mail validieren
|
||||||
const emailCheck = await verifyEmailDomain(email);
|
const emailCheck = await verifyEmailDomain(email);
|
||||||
if (!emailCheck.valid) {
|
if (!emailCheck.valid) {
|
||||||
return res.json({ success: false, error: 'E-Mail-Adresse ist nicht erreichbar: ' + emailCheck.reason });
|
return res.json({ success: false, error: 'E-Mail-Adresse ist nicht erreichbar: ' + emailCheck.reason });
|
||||||
}
|
}
|
||||||
|
|
||||||
// IBAN prüfen (falls angegeben)
|
// IBAN prüfen
|
||||||
if (iban && iban.trim()) {
|
if (iban && iban.trim()) {
|
||||||
const ibanCheck = validateIBANServer(iban.trim());
|
const ibanCheck = validateIBANServer(iban.trim());
|
||||||
if (!ibanCheck.valid) {
|
if (!ibanCheck.valid) {
|
||||||
@ -87,49 +94,67 @@ router.post('/submit-membership', async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pflichtfelder prüfen
|
|
||||||
if (!tariff_id || !first_name || !last_name || !birth_date || !email || !street || !zip || !city) {
|
|
||||||
return res.json({ success: false, error: 'Bitte alle Pflichtfelder ausfüllen.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!agb_accepted || !datenschutz_accepted || !data_correct) {
|
|
||||||
return res.json({ success: false, error: 'Bitte alle Einverständniserklärungen bestätigen.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Alter berechnen
|
// Alter berechnen
|
||||||
const birthDateObj = new Date(birth_date);
|
const birthDateObj = new Date(birth_date);
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
let age = today.getFullYear() - birthDateObj.getFullYear();
|
let age = today.getFullYear() - birthDateObj.getFullYear();
|
||||||
const m = today.getMonth() - birthDateObj.getMonth();
|
const mo = today.getMonth() - birthDateObj.getMonth();
|
||||||
if (m < 0 || (m === 0 && today.getDate() < birthDateObj.getDate())) age--;
|
if (mo < 0 || (mo === 0 && today.getDate() < birthDateObj.getDate())) age--;
|
||||||
|
|
||||||
const is_minor = age < 18 ? 1 : 0;
|
const is_minor = age < 18 ? 1 : 0;
|
||||||
|
|
||||||
if (is_minor && !guardian_consent) {
|
if (is_minor && !guardian_consent) {
|
||||||
return res.json({ success: false, error: 'Bei Minderjährigen ist die Einverständniserklärung der Erziehungsberechtigten erforderlich.' });
|
return res.json({ success: false, error: 'Bei Minderjährigen ist die Einverständniserklärung der Erziehungsberechtigten erforderlich.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (age < 14) {
|
if (age < 14) {
|
||||||
return res.json({ success: false, error: 'Das Mindestalter für eine Mitgliedschaft beträgt 14 Jahre.' });
|
return res.json({ success: false, error: 'Das Mindestalter für eine Mitgliedschaft beträgt 14 Jahre.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tarif prüfen
|
// Tarif laden — MUSS vor Berechnungen stehen
|
||||||
const [tariffs] = await db.query('SELECT * FROM tariffs WHERE id = ? AND active = 1', [tariff_id]);
|
const [tariffs] = await db.query('SELECT * FROM tariffs WHERE id = ? AND active = 1', [tariff_id]);
|
||||||
if (tariffs.length === 0) {
|
if (tariffs.length === 0) {
|
||||||
return res.json({ success: false, error: 'Ungültiger oder inaktiver Tarif.' });
|
return res.json({ success: false, error: 'Ungültiger oder inaktiver Tarif.' });
|
||||||
}
|
}
|
||||||
|
const tariff = tariffs[0];
|
||||||
|
|
||||||
|
// Startpaket-Preis
|
||||||
|
const startPackagePrice = parseFloat(tariff.start_package_price) || 35.00;
|
||||||
|
|
||||||
|
// Vertragsbeginn = heute + 15 Tage
|
||||||
|
const contractStart = new Date();
|
||||||
|
contractStart.setDate(contractStart.getDate() + 15);
|
||||||
|
|
||||||
|
// Anteiligen ersten Monatsbeitrag berechnen
|
||||||
|
const daysInMonth = new Date(contractStart.getFullYear(), contractStart.getMonth() + 1, 0).getDate();
|
||||||
|
const remainingDays = daysInMonth - contractStart.getDate() + 1;
|
||||||
|
const dailyRate = parseFloat(tariff.price_monthly) / daysInMonth;
|
||||||
|
const partialMonth = Math.round(dailyRate * remainingDays * 100) / 100;
|
||||||
|
|
||||||
|
// Erste Zahlung = anteiliger Monat + Startpaket
|
||||||
|
const firstPaymentAmt = Math.round((partialMonth + startPackagePrice) * 100) / 100;
|
||||||
|
|
||||||
|
// Vertragsende
|
||||||
|
const contractEnd = new Date(contractStart);
|
||||||
|
contractEnd.setMonth(contractEnd.getMonth() + tariff.duration_months);
|
||||||
|
contractEnd.setDate(contractEnd.getDate() - 1);
|
||||||
|
|
||||||
// Zugangstoken generieren
|
// Zugangstoken generieren
|
||||||
const access_token = crypto.randomBytes(16).toString('hex');
|
const access_token = crypto.randomBytes(16).toString('hex');
|
||||||
|
|
||||||
// In DB speichern
|
// In DB speichern
|
||||||
|
// Spalten: 26, Werte: 26
|
||||||
await db.query(`
|
await db.query(`
|
||||||
INSERT INTO memberships
|
INSERT INTO memberships
|
||||||
(tariff_id, salutation, title, first_name, last_name, birth_date, email, phone,
|
(tariff_id, salutation, title, first_name, last_name, birth_date,
|
||||||
street, address_addition, zip, city, bank_name, account_holder, iban,
|
email, phone, street, address_addition, zip, city,
|
||||||
sepa_accepted, agb_accepted, datenschutz_accepted, data_correct, guardian_consent, is_minor, access_token,
|
bank_name, account_holder, iban,
|
||||||
agreed_price, agreed_duration)
|
sepa_accepted, agb_accepted, datenschutz_accepted, data_correct,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
guardian_consent, is_minor, access_token,
|
||||||
|
agreed_price, agreed_duration,
|
||||||
|
start_package_price,
|
||||||
|
signup_date, contract_start, contract_end, effective_end,
|
||||||
|
first_payment_date, first_payment_amt)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`, [
|
`, [
|
||||||
tariff_id, salutation, title || '', first_name, last_name, birth_date,
|
tariff_id, salutation, title || '', first_name, last_name, birth_date,
|
||||||
email, phone || '', street, address_addition || '', zip, city,
|
email, phone || '', street, address_addition || '', zip, city,
|
||||||
@ -137,7 +162,14 @@ router.post('/submit-membership', async (req, res) => {
|
|||||||
sepa_accepted ? 1 : 0, agb_accepted ? 1 : 0,
|
sepa_accepted ? 1 : 0, agb_accepted ? 1 : 0,
|
||||||
datenschutz_accepted ? 1 : 0, data_correct ? 1 : 0,
|
datenschutz_accepted ? 1 : 0, data_correct ? 1 : 0,
|
||||||
guardian_consent ? 1 : 0, is_minor, access_token,
|
guardian_consent ? 1 : 0, is_minor, access_token,
|
||||||
tariffs[0].price_monthly, tariffs[0].duration_months
|
tariff.price_monthly, tariff.duration_months,
|
||||||
|
startPackagePrice,
|
||||||
|
today.toISOString().split('T')[0],
|
||||||
|
contractStart.toISOString().split('T')[0],
|
||||||
|
contractEnd.toISOString().split('T')[0],
|
||||||
|
contractEnd.toISOString().split('T')[0],
|
||||||
|
contractStart.toISOString().split('T')[0],
|
||||||
|
firstPaymentAmt
|
||||||
]);
|
]);
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
|
|||||||
@ -13,16 +13,23 @@ function calcInvoiceAmount(member, period) {
|
|||||||
// Pausiert → 0€
|
// Pausiert → 0€
|
||||||
if (member.status === 'paused') return 0;
|
if (member.status === 'paused') return 0;
|
||||||
|
|
||||||
// Vereinbarten Preis verwenden (nicht den aktuellen Tarif-Preis!)
|
|
||||||
const price = parseFloat(member.agreed_price || member.price_monthly);
|
const price = parseFloat(member.agreed_price || member.price_monthly);
|
||||||
|
|
||||||
// Erster Monat (anteilig)?
|
|
||||||
const firstPeriod = member.first_payment_date
|
const firstPeriod = member.first_payment_date
|
||||||
? member.first_payment_date.toISOString().substring(0, 7)
|
? new Date(member.first_payment_date).toISOString().substring(0, 7)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (firstPeriod === period && member.first_payment_amt) {
|
if (firstPeriod === period) {
|
||||||
return parseFloat(member.first_payment_amt);
|
// 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;
|
return price;
|
||||||
@ -78,7 +85,8 @@ router.get('/', requireAdmin, async (req, res) => {
|
|||||||
// Vorschau: Mitglieder die noch keine Rechnung für diese Periode haben
|
// Vorschau: Mitglieder die noch keine Rechnung für diese Periode haben
|
||||||
const [eligible] = await db.query(`
|
const [eligible] = await db.query(`
|
||||||
SELECT m.*, t.price_monthly, t.name as tariff_name,
|
SELECT m.*, t.price_monthly, t.name as tariff_name,
|
||||||
COALESCE(m.agreed_price, t.price_monthly) as agreed_price
|
COALESCE(m.agreed_price, t.price_monthly) as agreed_price,
|
||||||
|
COALESCE(m.start_package_price, 0) as start_package_price
|
||||||
FROM memberships m
|
FROM memberships m
|
||||||
JOIN tariffs t ON m.tariff_id = t.id
|
JOIN tariffs t ON m.tariff_id = t.id
|
||||||
WHERE m.status IN ('active','paused')
|
WHERE m.status IN ('active','paused')
|
||||||
@ -124,7 +132,8 @@ router.post('/run', requireAdmin, async (req, res) => {
|
|||||||
// Alle aktiven/pausierten Mitglieder im Vertragszeitraum
|
// Alle aktiven/pausierten Mitglieder im Vertragszeitraum
|
||||||
const [members] = await db.query(`
|
const [members] = await db.query(`
|
||||||
SELECT m.*, t.price_monthly, t.name as tariff_name,
|
SELECT m.*, t.price_monthly, t.name as tariff_name,
|
||||||
COALESCE(m.agreed_price, t.price_monthly) as agreed_price
|
COALESCE(m.agreed_price, t.price_monthly) as agreed_price,
|
||||||
|
COALESCE(m.start_package_price, 0) as start_package_price
|
||||||
FROM memberships m
|
FROM memberships m
|
||||||
JOIN tariffs t ON m.tariff_id = t.id
|
JOIN tariffs t ON m.tariff_id = t.id
|
||||||
WHERE m.status IN ('active','paused')
|
WHERE m.status IN ('active','paused')
|
||||||
|
|||||||
@ -165,6 +165,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="karte-row">
|
||||||
|
<div class="karte-field">
|
||||||
|
<label>Startpaket (€) <small>(0 = erlassen)</small></label>
|
||||||
|
<input type="number" name="start_package_price" step="0.01" min="0"
|
||||||
|
value="<%= member.start_package_price != null ? Number(member.start_package_price).toFixed(2) : '35.00' %>"
|
||||||
|
disabled class="karte-input">
|
||||||
|
</div>
|
||||||
|
<div class="karte-field">
|
||||||
|
<label>Startpaket abgerechnet</label>
|
||||||
|
<input type="text"
|
||||||
|
value="<%= member.start_package_paid ? '✅ Ja' : '❌ Nein' %>"
|
||||||
|
disabled class="karte-input karte-readonly">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="karte-row">
|
<div class="karte-row">
|
||||||
<div class="karte-field">
|
<div class="karte-field">
|
||||||
<label>Abschlussdatum</label>
|
<label>Abschlussdatum</label>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user